Módulo 25: Internacionalización y PWA

Objetivos del módulo

  • Distinguir internacionalización, localización y locale, y saber qué cambia de un idioma a otro además del texto
  • Preparar un proyecto con @angular/localize y entender qué modifica ng add
  • Marcar textos en plantillas con i18n y en TypeScript con $localize, con descripción, significado e identificador propio
  • Escribir plurales y selecciones con la sintaxis ICU, y entender las categorías plurales de cada idioma
  • Extraer los textos con ng extract-i18n, traducirlos y mantener los ficheros cuando la aplicación cambia
  • Compilar una aplicación por idioma, y saber qué genera ng build y qué ocurre por dentro
  • Usar LOCALE_ID y cambiar de idioma mediante enlaces, y servir cada idioma con Node o Nginx
  • Comparar la traducción en compilación con la traducción en ejecución, y usar loadTranslations
  • Explicar qué es una PWA y qué hace un service worker
  • Convertir la aplicación EMPRESA en una PWA con ng add @angular/pwa y ajustar ngsw-config.json
  • Guardar respuestas de la API con dataGroups y elegir entre performance y freshness
  • Probar de verdad el modo sin conexión y depurar el service worker
  • Avisar de versiones nuevas con SwUpdate y tratar los estados irrecuperables
  • Configurar el manifest web y conocer las notificaciones push con SwPush
  • Combinar los dos temas: un service worker independiente por idioma

💡 Consejo: Si usas la LTS (Angular 21), este módulo te sirve casi entero tal cual. Hemos comparado los paquetes @angular/localize y @angular/service-worker de 21.2.25 y 22.2.4: tienen las mismas APIs públicas y el mismo comportamiento, y la aplicación del módulo compila en ambas versiones. Lo único marcado son los servicios escritos con @Service(). Los ejemplos usan además httpResource, que en la LTS es experimental, como explica el módulo 13.


1. Qué significa internacionalizar

Hasta ahora, la aplicación EMPRESA habla español porque los textos están escritos directamente en las plantillas. Supón que la empresa abre una oficina en Londres y otra en Lyon. Traducir no consiste solo en cambiar unas frases, porque también cambian muchas cosas que no parecen texto:

Dato Español (España) Inglés (Estados Unidos) Francés (Francia)
Importe 2.600,00 € €2,600.00 2 600,00 €
Fecha 16 mar 2020 Mar 16, 2020 16 mars 2020
Plural con cero 0 empleados 0 employees 0 employé
Orden de las palabras Datos a 2 de noviembre Data as of November 2 Données au 2 novembre

Los importes y las fechas son salidas reales de la aplicación de este módulo. Fíjate en el plural francés, que usa el singular para el cero.

Hay tres términos que conviene separar:

  • Internacionalización (i18n, porque entre la i y la n de internationalization hay 18 letras): preparar el código para que pueda funcionar en varios idiomas. La hace quien programa.
  • Localización (l10n): adaptar la aplicación ya preparada a un idioma y región concretos. La hace quien traduce, con ayuda del compilador.
  • Locale: el identificador de un idioma y, opcionalmente, una región: es, es-MX, en-GB, fr. Determina los formatos de fechas y números, y las reglas de plural.

📘 Concepto: El módulo 07 ya mostró LOCALE_ID y registerLocaleData para formatear fechas y números en español. Eso resuelve el formato, pero no traduce los textos. @angular/localize añade la parte que faltaba y, como verás, también se encarga de configurar el formato de cada idioma.

Angular trabaja por defecto en compilación: marcas los textos, los extraes a un fichero, los traduces, y ng build genera una copia completa de la aplicación por cada idioma.

Cinco pasos en cadena: marcar, extraer, traducir, compilar y desplegar, con una flecha que vuelve de traducir a marcar para los textos nuevos

Figura 1. El flujo completo. Lo repetirás cada vez que añadas o cambies textos.

Este módulo se apoya en una aplicación pequeña con dos pantallas, empleados y departamentos, que usa la API EMPRESA. Las capturas son reales y está probada en tres idiomas: español (idioma original), inglés y francés.


2. Preparar el proyecto

El soporte de traducción está en un paquete aparte:

ng add @angular/localize

Esta orden instala @angular/localize y toca cuatro cosas:

Fichero Cambio Para qué sirve
package.json Añade la dependencia Incluye la función $localize y las herramientas de traducción
angular.json "polyfills": ["@angular/localize/init"] Hace que $localize exista como global en el navegador
tsconfig.app.json y tsconfig.spec.json "types": ["@angular/localize"] TypeScript conoce $localize
src/main.ts /// <reference types="@angular/localize" /> Lo mismo, desde el punto de entrada

Después declara en angular.json en qué idioma está escrito el código y a qué idiomas lo traducirás. Va dentro del proyecto, al mismo nivel que architect:

{
  "projects": {
    "empresa": {
      "i18n": {
        "sourceLocale": "es",
        "locales": {
          "en": { "translation": "src/locale/messages.en.xlf" },
          "fr": { "translation": "src/locale/messages.fr.xlf" }
        }
      },
      "architect": {
      }
    }
  }
}

⚠️ Importante: sourceLocale vale en-US si no lo indicas. Si escribes los textos en español y olvidas esta línea, los ficheros de traducción dirán que el idioma original es el inglés, y las fechas de la versión original saldrán en formato estadounidense.


3. Marcar textos en las plantillas

Marcar un texto consiste en añadir el atributo i18n al elemento que lo contiene:

<h1 i18n>Directorio de empleados</h1>

El atributo no llega al HTML final. Es una instrucción para el compilador, que dice: «el contenido de este elemento hay que traducirlo».

Descripción, significado e identificador

Quien traduce suele ver una lista de frases sueltas, sin la pantalla delante. Si encuentra la palabra «Alta», no sabe si es una fecha de incorporación o una estatura. El atributo admite tres datos, con este formato:

i18n="significado|descripción@@identificador"
<h1 i18n="Pantalla principal|Título del directorio de empleados@@directorio.titulo">
  Directorio de empleados
</h1>

<th i18n="Fecha de incorporación@@directorio.col.alta">Alta</th>
Parte Obligatoria Efecto
Significado No Distingue dos usos del mismo texto. Sin identificador propio, forma parte del identificador generado
Descripción No Solo informa a quien traduce. Con un único dato antes de @@, Angular lo toma como descripción
@@identificador No Nombre propio y estable del mensaje

Sin identificador propio, Angular calcula uno a partir del texto y del significado, por ejemplo 8510684221969247992. Eso tiene una consecuencia importante: si corriges una coma del texto original, cambia el identificador y la traducción deja de encontrarse. Con @@directorio.titulo, el texto puede cambiar sin perder la relación.

💡 Consejo: Usa identificadores propios con una jerarquía sencilla (pantalla.elemento). En un proyecto pequeño puede parecer un trabajo extra, pero evita traducir de nuevo por cambios mínimos y hace legibles los ficheros y los diffs.

Con el mismo identificador y distinto texto, el compilador avisa de un duplicado. Se controla con la opción i18nDuplicateTranslation, que admite warning, error o ignore.

Interpolaciones y elementos dentro del texto

Las frases se marcan enteras, con todo lo que llevan dentro:

<p i18n="@@directorio.intro">
  Consulta la plantilla de <strong>{{ empresa }}</strong>. Datos a {{ hoy | date: 'longDate' }}.
</p>

En el fichero de traducción aparecerán marcadores en el lugar de la negrita y de las interpolaciones. Quien traduce puede moverlos, porque el orden de las palabras cambia de un idioma a otro. Por eso nunca debes trocear una frase en varios elementos marcados por separado:

<!-- MAL: la traducción no puede reordenar la frase -->
<span i18n>Hay</span> {{ total }} <span i18n>empleados</span>

<!-- BIEN: una sola unidad de traducción -->
<span i18n>Hay {{ total }} empleados</span>

Atributos

El texto de un atributo se marca con i18n-<atributo>:

<input
  type="search"
  placeholder="Nombre o apellidos"
  i18n-placeholder="Ayuda dentro del buscador@@directorio.buscar.ayuda"
  (input)="buscar($event)"
/>

<nav aria-label="Menú principal" i18n-aria-label="@@menu.etiqueta">...</nav>

Recuerda title, alt, placeholder y los atributos aria-*. Son los que más se olvidan, y los lectores de pantalla los leen en voz alta, como viste en el módulo 21.

Texto sin elemento propio

Si el texto no tiene un elemento y no quieres añadir uno, usa ng-container, que no genera HTML:

<ng-container i18n="@@aviso.resumen">Revisa los datos antes de guardar</ng-container>

4. Textos en TypeScript con $localize

No todos los textos están en plantillas. Un mensaje de error, el título de una ruta o una etiqueta calculada viven en el código. Para ellos existe $localize, una función que se usa como plantilla etiquetada de JavaScript (tagged template):

// Sin metadatos
const aviso = $localize`Datos guardados`;

// Con descripción e identificador, entre dos puntos al principio
protected readonly mensajeError =
  $localize`:Aviso cuando falla la API@@directorio.error:No se pudo cargar la lista de empleados`;

Los metadatos usan el mismo formato que i18n: :significado|descripción@@id:texto. Y las variables pueden llevar un nombre para el marcador, escrito justo después de la expresión:

const saludo = $localize`:@@saludo:Hola, ${nombre}:persona:. Tienes acceso.`;

Así, quien traduce ve {$persona} en lugar de un genérico {$PH}.

Los títulos de ruta, que viste en el módulo 10, son un caso muy común:

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { Directorio } from './directorio/directorio';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'empleados' },
  {
    path: 'empleados',
    title: $localize`:Título de la pestaña@@ruta.empleados:Empleados | EMPRESA`,
    component: Directorio,
  },
  {
    path: 'departamentos',
    title: $localize`:Título de la pestaña@@ruta.departamentos:Departamentos | EMPRESA`,
    loadComponent: () =>
      import('./departamentos/departamentos').then((m) => m.Departamentos),
  },
];

⚠️ Importante: La plantilla etiquetada debe ser literal. $localize no puede traducir un texto que construyes tú en una variable, ni uno que llega de la API. Los nombres de empleados, los oficios o las descripciones de productos son datos, no textos de la interfaz. En las capturas verás que PROGRAMADOR no se traduce. Traducir datos es responsabilidad del servidor.


5. Plurales y selecciones

Escribir {{ total }} empleados es correcto para 2, pero no para 1. Un @if por cada caso no sirve, porque cada idioma tiene reglas distintas. La solución es la sintaxis ICU (International Components for Unicode), un estándar para expresar variantes de un mensaje.

Plurales

<p class="total" i18n="@@directorio.total">
  {total, plural,
    =0 {No hay empleados}
    =1 {Hay un empleado}
    other {Hay {{ total }} empleados}
  }
</p>

La estructura es {expresión, plural, caso {texto} caso {texto}}. Los casos con = comparan con un número exacto. Los demás son categorías que dependen del idioma, y other es obligatoria:

Categoría Uso típico
zero, one, two, few, many Grupos de números según las reglas de cada idioma
other Todo lo demás

Ninguna categoría significa lo que parece. one no es «el número uno», sino «los números que se tratan como singular en este idioma». Este programa lo muestra con Intl.PluralRules, la API que viste en el módulo 07:

for (const idioma of ['es', 'en', 'fr', 'ru', 'ar', 'ja']) {
  const categorias = [0, 1, 2, 5, 21, 1000000].map((n) => new Intl.PluralRules(idioma).select(n));
  console.log(idioma, categorias.join(' '));
}
es other one other other other many
en other one other other other other
fr one one other other other many
ru many one few many one many
ar zero one two few many other
ja other other other other other other

La salida es muy reveladora. En francés, el 0 es one. El ruso trata el 21 igual que el 1, porque acaba en uno. El árabe usa las seis categorías. El japonés no distingue nada. Y en español, un millón es many: se dice «un millón de empleados», no «un millón empleados».

Por eso, cada traducción puede usar casos diferentes de los del original. Este es el plural francés de la aplicación:

{VAR_PLURAL, plural, =0 {Aucun employé} one {<x id="INTERPOLATION"/> employé} other {<x id="INTERPOLATION"/> employés}}

Selecciones

select elige un texto según el valor de una cadena. Sirve para estados, géneros o cualquier conjunto cerrado de opciones:

<span class="insignia" [class.apagada]="modo() === 'offline'" i18n="@@conexion.estado">
  {modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}
</span>

Las claves online y offline no se traducen, porque son los valores que devuelve modo(). Solo se traducen los textos entre llaves. other también es obligatoria aquí.

Un plural y un select se pueden anidar, como harás en el ejercicio 2. Sirve para concordar, por ejemplo, género y número.

💡 Consejo: Un select también sirve para sustituir una construcción habitual y dañina: {{ activo ? 'Sí' : 'No' }}. Esos literales dentro de una expresión no se pueden marcar con i18n, así que quedan sin traducir sin ningún aviso.


6. Extraer los textos

Con todo marcado, esta orden recorre la aplicación y escribe un fichero con cada mensaje:

ng extract-i18n --output-path src/locale
Application bundle generation complete.
Extraction Complete. (Messages: 23)

📘 Concepto: extract-i18n compila la aplicación antes de extraer. Por eso tarda lo mismo que un ng build y falla si hay errores de compilación. Solo extrae los textos de lo que forma parte de la aplicación: un componente que nadie importa no aparece, aunque lleve i18n.

El resultado es src/locale/messages.xlf, en formato XLIFF 1.2, un estándar XML que entienden las herramientas de traducción. Esta es una de sus unidades:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="es" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="menu.etiqueta" datatype="html">
        <source>Menú principal</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.html</context>
          <context context-type="linenumber">4</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>
Opción Efecto
--output-path Carpeta de salida
--out-file Nombre del fichero, en lugar de messages.xlf
--format xlf (por defecto), xlf2, xmb, json, arb

Un plural genera dos unidades

Una sorpresa frecuente: hemos marcado un solo <span> con el select y aparecen dos unidades:

<trans-unit id="conexion.estado" datatype="html">
  <source> <x id="ICU" equiv-text="{modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}" xid="2298327741772557399"/> </source>
</trans-unit>
<trans-unit id="2298327741772557399" datatype="html">
  <source>{VAR_SELECT, select, online {Conectado} offline {Sin conexión} other {Desconocido}}</source>
</trans-unit>

La primera es el contenido del elemento, con un marcador ICU. La segunda es la expresión ICU, con su identificador calculado. Hay que traducir la segunda y copiar la primera tal cual. Si cambias el texto de cualquier caso, el identificador de la unidad ICU cambia, aunque el i18n tenga @@.


7. Traducir

Copia el fichero una vez por idioma y añade un elemento <target> a cada unidad, justo después de <source>:

cp src/locale/messages.xlf src/locale/messages.en.xlf
cp src/locale/messages.xlf src/locale/messages.fr.xlf
<trans-unit id="directorio.titulo" datatype="html">
  <source> Directorio de empleados </source>
  <target>Employee directory</target>
</trans-unit>
<trans-unit id="8510684221969247992" datatype="html">
  <source>{VAR_PLURAL, plural, =0 {No hay empleados} =1 {Hay un empleado} other {Hay <x id="INTERPOLATION"/> empleados}}</source>
  <target>{VAR_PLURAL, plural, =0 {No employees} =1 {One employee} other {<x id="INTERPOLATION"/> employees}}</target>
</trans-unit>

Las reglas son sencillas: no cambies los id, conserva los marcadores <x .../>, aunque puedes moverlos, y traduce solo el texto. En una frase con negrita, el destino debe tener también START_TAG_STRONG y CLOSE_TAG_STRONG:

<target>Browse the staff of <x id="START_TAG_STRONG" ctype="x-strong" equiv-text="&lt;strong&gt;"/><x id="INTERPOLATION" equiv-text="{{ empresa }}"/><x id="CLOSE_TAG_STRONG" ctype="x-strong" equiv-text="&lt;/strong&gt;"/>. Data as of <x id="INTERPOLATION_1" equiv-text="..."/>.</target>

En equipos reales, estos ficheros no se editan a mano: se suben a una herramienta de traducción que muestra el contexto y conserva lo ya traducido.

Mantener las traducciones

Cuando añades textos, repites la extracción. extract-i18n no toca messages.en.xlf ni messages.fr.xlf: solo regenera messages.xlf. Eres tú quien debe llevar las unidades nuevas a cada fichero traducido. Puedes comparar los id con un pequeño programa, con una herramienta de traducción o con un paquete de la comunidad.

Ese paso olvidado es el error más común, y el compilador puede vigilarlo con i18nMissingTranslation:

Valor Si falta una unidad
warning (por defecto) Aviso, y se usa el texto original
error La compilación falla
ignore Se usa el texto original sin decir nada
"options": {
  "i18nMissingTranslation": "error"
}

Esta es la salida real al compilar en francés con textos nuevos cuyas unidades no están en messages.fr.xlf. Con warning son avisos y la compilación termina. Con error aparecen estas mismas líneas como errores y no se genera nada:

X [ERROR] No translation found for "pie.privacidad" ("Privacidad").
X [ERROR] No translation found for "pie.legal" (" © {$INTERPOLATION} EMPRESA S.A. Todos los derechos reservados. " - "Pie de página").
X [ERROR] No translation found for "pedidos.resumen" (" {$ICU} ").

⚠️ Importante: Una unidad que existe sin <target> no cuenta como traducción que falta. Lo hemos comprobado con i18nMissingTranslation en error: la compilación termina bien y la versión francesa muestra el texto en español. Copiar un messages.xlf entero y dejar unidades a medias es una forma silenciosa de que se cuelen textos sin traducir.


8. Compilar por idioma

Con los ficheros traducidos, activa localize en la configuración de producción de angular.json:

"configurations": {
  "production": {
    "localize": true,
    "outputHashing": "all"
  },
  "en": { "localize": ["en"] },
  "fr": { "localize": ["fr"] }
}

true compila todos los idiomas, incluido el original, y una lista compila solo los indicados. El resultado de ng build es este:

dist/empresa/
  3rdpartylicenses.txt
  browser/
    en/
    es/
    fr/

Cada carpeta es una aplicación completa, y el index.html de cada una se ha adaptado solo:

<!doctype html>
<html lang="en" dir="ltr" data-beasties-container>
<head>
  <meta charset="utf-8">
  <title>EMPRESA</title>
  <base href="/en/">

Angular ha cambiado tres cosas: lang, dir, con la dirección del texto según los datos del idioma, y base href, que apunta a la carpeta. Esa última es imprescindible, porque el router y las rutas relativas de los ficheros dependen de ella. Sin embargo, <title> no se ha traducido: el título real lo pone el router cuando navega, y por eso lo traducimos con $localize en las rutas.

Estas son las capturas de la misma pantalla en los tres idiomas:

Directorio de empleados en español: importes como 2.600,00 € y fechas como 16 mar 2020

Figura 2. Versión original en /es/empleados. El oficio es un dato de la API y no se traduce en ningún idioma.

Directorio de empleados en inglés: importes como €2,600.00 y fechas como Mar 16, 2020

Figura 3. Versión inglesa en /en/empleados.

Annuaire des employés en francés: importes como 2 600,00 € y fechas como 16 mars 2020

Figura 4. Versión francesa en /fr/empleados. La moneda es la misma, y el formato, otro.

Qué ocurre por dentro

Compilar ocho idiomas no significa compilar ocho veces. El compilador convierte cada i18n en una llamada a $localize. Al terminar, una herramienta recorre el JavaScript ya empaquetado y sustituye cada llamada por el texto del idioma correspondiente, una vez por idioma. Eso es muy rápido comparado con compilar de nuevo.

Se aprecia en el resultado. En nuestra compilación, las tres carpetas contienen ficheros con el mismo nombre, aunque el contenido sea distinto:

 104474 es/main-NEODMM5K.js
 104496 fr/main-NEODMM5K.js
   2247 es/polyfills-B6AWBRSW.js
   2823 fr/polyfills-B6AWBRSW.js

El hash del nombre se calculó antes de traducir. Es otra razón para que cada idioma viva en su carpeta y para no mezclarlas al desplegar. El fichero polyfills cambia porque lleva los datos de formato del idioma. Por eso, en este tipo de compilación no necesitas registerLocaleData: el compilador lo hace por ti.

Probar un idioma en desarrollo

ng serve no compila todos los idiomas: sirve uno solo. Las configuraciones en y fr anteriores existen para esto, y hay que enlazarlas en el objetivo serve de angular.json:

"serve": {
  "builder": "@angular/build:dev-server",
  "configurations": {
    "en": { "buildTarget": "empresa:build:development,en" },
    "fr": { "buildTarget": "empresa:build:development,fr" }
  }
}
ng serve --configuration=fr

Hemos comprobado qué sirve: <html lang="fr" dir="ltr">, pero con <base href="/">. La aplicación francesa queda en la raíz, sin el prefijo /fr/, y las unidades que faltan salen como avisos en el terminal. Por eso, los enlaces de cambio de idioma de la sección 10 no funcionan con ng serve: para probarlos necesitas la compilación completa.

Cambiar las rutas de salida

Por defecto, el nombre de la carpeta y el base href son el código del idioma. La propiedad subPath cambia los dos a la vez:

"i18n": {
  "sourceLocale": { "code": "es", "subPath": "espanol" },
  "locales": {
    "en": { "translation": "src/locale/messages.en.xlf", "subPath": "english" }
  }
}

Solo admite letras, números, guiones y guiones bajos. También existe baseHref, pero el constructor recomienda subPath, y no se pueden usar juntas.


9. El idioma en ejecución: LOCALE_ID

Cada compilación recibe su valor de LOCALE_ID automáticamente. No hay que declararlo en app.config.ts. Los pipes date, number, currency y percent lo usan, y tú puedes leerlo con inject:

protected readonly idiomaActual = inject(LOCALE_ID); // 'es', 'en' o 'fr'

Sirve también para lo que Angular no formatea. Este pipe, que harás en el ejercicio 4, crea un Intl.NumberFormat con el idioma de la aplicación:

@Pipe({ name: 'compacta' })
export class CompactaPipe implements PipeTransform {
  private readonly formato = new Intl.NumberFormat(inject(LOCALE_ID), {
    notation: 'compact',
    maximumFractionDigits: 1,
  });

  transform(valor: number): string {
    return this.formato.format(valor);
  }
}

Recuerda que LOCALE_ID no decide la moneda ni la zona horaria, como viste en el módulo 07. Un cliente en Londres sigue viendo euros si el importe está en euros: en las figuras 3 y 4, la divisa no cambia y el formato sí. Convertir importes es una decisión del negocio, no del idioma.


10. Cambiar de idioma

Como cada idioma es una aplicación distinta, cambiar de idioma es navegar a otra URL y recargar la página. No se usa routerLink, porque el router solo conoce las rutas de su propia aplicación. Se usa un enlace normal:

// src/app/app.ts
import { Component, LOCALE_ID, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter, map } from 'rxjs';
import { Actualizaciones } from './pwa/actualizaciones';
import { Conexion } from './pwa/conexion';

@Component({
  selector: 'app-root',
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {
  private readonly router = inject(Router);
  protected readonly idiomaActual = inject(LOCALE_ID);
  protected readonly conexion = inject(Conexion);
  protected readonly actualizaciones = inject(Actualizaciones);

  // El nombre de cada idioma va en su propio idioma: no se traduce
  private readonly idiomas = [
    { codigo: 'es', nombre: 'Español' },
    { codigo: 'en', nombre: 'English' },
    { codigo: 'fr', nombre: 'Français' },
  ];

  private readonly ruta = toSignal(
    this.router.events.pipe(
      filter((e) => e instanceof NavigationEnd),
      map((e) => e.urlAfterRedirects),
    ),
    { initialValue: '/' },
  );

  // Cada enlace conserva la pantalla actual al cambiar de idioma
  protected readonly enlaces = computed(() =>
    this.idiomas.map((i) => ({ ...i, url: `/${i.codigo}${this.ruta()}` })),
  );

  protected readonly modo = computed(() => (this.conexion.enLinea() ? 'online' : 'offline'));
}

Conexion y Actualizaciones son dos servicios que escribiremos en la parte de PWA. La plantilla completa de esta aplicación es esta:

<!-- src/app/app.html -->
<header>
  <strong class="marca">EMPRESA</strong>

  <nav aria-label="Menú principal" i18n-aria-label="@@menu.etiqueta">
    <a routerLink="/empleados" routerLinkActive="activo" i18n="@@menu.empleados">Empleados</a>
    <a routerLink="/departamentos" routerLinkActive="activo" i18n="@@menu.departamentos">Departamentos</a>
  </nav>

  <span class="insignia" [class.apagada]="modo() === 'offline'" i18n="@@conexion.estado">
    {modo(), select, online {Conectado} offline {Sin conexión} other {Desconocido}}
  </span>

  <ul class="idiomas">
    @for (i of enlaces(); track i.codigo) {
      <li>
        <a [href]="i.url" [attr.lang]="i.codigo" [attr.hreflang]="i.codigo"
           [class.activo]="i.codigo === idiomaActual">{{ i.nombre }}</a>
      </li>
    }
  </ul>
</header>

@if (actualizaciones.versionRota()) {
  <aside class="aviso">
    <span i18n="@@pwa.rota">Esta versión ya no se puede usar. Recarga la página.</span>
  </aside>
} @else if (actualizaciones.hayVersionNueva()) {
  <aside class="aviso">
    <span i18n="@@pwa.nueva">Hay una versión nueva de la aplicación.</span>
    <button type="button" (click)="actualizaciones.aplicar()" i18n="@@pwa.actualizar">Actualizar</button>
  </aside>
}

<main>
  <router-outlet />
</main>

El atributo lang del enlace no es decorativo. Sin él, un lector de pantalla configurado en inglés pronunciaría «Français» con reglas inglesas. Y fíjate en que los idiomas se muestran por su propio nombre: quien busca el francés reconoce «Français», no «Francés». No uses banderas: una bandera representa un país, no un idioma, y el español no pertenece a un solo país.

Servir los idiomas

La carpeta dist/empresa/browser tiene tres aplicaciones, y necesitamos dos cosas: que una URL de Angular como /en/departamentos reciba el index.html de su idioma, y decidir qué hacer con quien entra en /. Lo normal es mirar la cabecera Accept-Language, que el navegador envía con los idiomas preferidos del usuario. Este servidor hace las dos cosas para probarlo en tu equipo, sin dependencias:

// servidor.mjs: sirve la compilación por idiomas, solo para probarla en tu equipo
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join } from 'node:path';

const RAIZ = 'dist/empresa/browser';
const IDIOMAS = ['es', 'en', 'fr'];
const POR_DEFECTO = 'es';
const PUERTO = Number(process.env.PORT ?? 8080);

const TIPOS = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript',
  '.css': 'text/css',
  '.json': 'application/json',
  '.webmanifest': 'application/manifest+json',
  '.png': 'image/png',
  '.ico': 'image/x-icon',
};

// "en-GB,en;q=0.9,es;q=0.8" => ['en', 'en', 'es']
function idiomaPreferido(cabecera = '') {
  const pedidos = cabecera.split(',').map((p) => p.trim().slice(0, 2).toLowerCase());
  return pedidos.find((i) => IDIOMAS.includes(i)) ?? POR_DEFECTO;
}

createServer((peticion, respuesta) => {
  const { pathname } = new URL(peticion.url, 'http://servidor');
  const idioma = pathname.split('/')[1];

  // Sin idioma en la URL: elegimos uno y redirigimos
  if (!IDIOMAS.includes(idioma)) {
    const destino = idiomaPreferido(peticion.headers['accept-language']);
    respuesta.writeHead(302, { Location: `/${destino}${pathname}`, Vary: 'Accept-Language' });
    respuesta.end();
    return;
  }

  let fichero = join(RAIZ, pathname);
  if (!existsSync(fichero) || !statSync(fichero).isFile()) {
    if (extname(pathname)) {
      respuesta.writeHead(404);
      respuesta.end('No encontrado');
      return;
    }
    // Una ruta de Angular, como /en/departamentos: se entrega el index de SU idioma
    fichero = join(RAIZ, idioma, 'index.html');
  }

  const tipo = TIPOS[extname(fichero)] ?? 'application/octet-stream';
  respuesta.writeHead(200, { 'Content-Type': tipo, 'Cache-Control': 'no-cache' });
  createReadStream(fichero).pipe(respuesta);
}).listen(PUERTO, '127.0.0.1', () => console.log(`http://127.0.0.1:${PUERTO}/`));
ng build
node servidor.mjs

Con un navegador en francés, al abrir http://127.0.0.1:8080/departamentos se redirige a /fr/departamentos, y el enlace «English» lleva a /en/departamentos, en la misma pantalla. La cabecera Vary avisa a las cachés intermedias de que la respuesta depende del idioma, para que no entreguen a todos la misma redirección.

En producción harás lo mismo con Nginx, que verás en el módulo 27. Esta configuración es la equivalente:

map $http_accept_language $idioma {
  default es;
  ~^en    en;
  ~^fr    fr;
}

server {
  listen 80;
  root /usr/share/nginx/html;
  absolute_redirect off;

  location = / {
    add_header Vary Accept-Language;
    return 302 /$idioma/;
  }

  location ~ /(ngsw\.json|index\.html)$ {
    add_header Cache-Control "no-cache";
  }

  location ~ ^/(es|en|fr)/ {
    try_files $uri /$1/index.html;
  }
}

La hemos probado en un contenedor con la imagen nginx:alpine:

Accept-Language: fr-FR,fr;q=0.9  ->  302 /fr/
Sin cabecera                     ->  302 /es/
GET /en/departamentos            ->  200, con base href="/en/"

map convierte la cabecera en la variable $idioma, y try_files entrega el fichero si existe o, si no, el index.html de la carpeta capturada en $1. Las reglas con expresión regular se comprueban en orden, así que la de no-cache debe ir antes. Una diferencia con servidor.mjs: aquí, un .js que no existe también recibe index.html con código 200, en lugar de un 404.

💡 Consejo: Respeta la elección manual. Si alguien entra desde un navegador en español y pulsa «English», no lo devuelvas al español la próxima vez que abra /. Esa decisión suele guardarse en una cookie que el servidor consulta antes que Accept-Language, y es lo que propone el ejercicio 10.


11. Traducir en ejecución

Una compilación por idioma tiene ventajas claras: máximo rendimiento y ningún coste por el idioma, y cada usuario descarga solo lo suyo. A cambio, cambiar de idioma recarga la página, y corregir una errata exige compilar y desplegar. Algunas aplicaciones necesitan otra cosa, por ejemplo cambiar de idioma sin perder lo que hay en la pantalla, o que el equipo de traducción publique cambios sin tocar el código.

A la izquierda, un build se convierte en tres carpetas con el texto incrustado. A la derecha, una sola aplicación busca claves en tres ficheros JSON

Figura 5. Las dos formas de entregar traducciones.

Criterio En compilación (@angular/localize) En ejecución (biblioteca)
Cambiar de idioma Recarga la página Inmediato
Rendimiento Sin coste, el texto ya está incrustado Busca cada texto y descarga un fichero extra
Despliegue Una carpeta por idioma Una sola aplicación
Corregir una traducción Compilar y desplegar Cambiar un JSON
Textos sin traducir Se detectan al compilar Se descubren al usar la aplicación
Varias zonas de idioma a la vez No Sí

Las bibliotecas de la comunidad más conocidas son Transloco y ngx-translate. Trabajan con claves y ficheros JSON, y no forman parte de Angular, así que debes comprobar que su versión es compatible con la tuya.

@angular/localize también tiene un modo en ejecución, menos conocido y más limitado. Sirve para no compilar por idioma y cargar la traducción una sola vez, antes de arrancar:

// src/main.ts
/// <reference types="@angular/localize" />
import { registerLocaleData } from '@angular/common';
import { LOCALE_ID } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { loadTranslations } from '@angular/localize';

async function arrancar() {
  const idioma = localStorage.getItem('idioma') ?? 'es';

  if (idioma === 'en') {
    const respuesta = await fetch('i18n/en.json');
    const { translations } = await respuesta.json();
    loadTranslations(translations);
  }
  const datos = idioma === 'en'
    ? await import('@angular/common/locales/en')
    : await import('@angular/common/locales/es');
  registerLocaleData(datos.default);

  // La aplicación se importa DESPUÉS de cargar las traducciones
  const { App } = await import('./app/app');
  const { appConfig } = await import('./app/app.config');

  await bootstrapApplication(App, {
    providers: [...appConfig.providers, { provide: LOCALE_ID, useValue: idioma }],
  });
}

arrancar().catch((error) => console.error(error));
{
  "locale": "en",
  "translations": {
    "directorio.titulo": "Employee directory",
    "menu.empleados": "Employees",
    "directorio.buscar": "Search",
    "ruta.empleados": "Employees | EMPRESA"
  }
}

Lo hemos probado con ese fichero, que solo traduce cuatro textos. Con idioma en en, la aplicación muestra Employee directory y Employees | EMPRESA en la pestaña, y los importes salen como €2,600.00. Los textos que faltan se quedan en español, y la consola los avisa uno a uno:

[warning] No translation found for "directorio.col.nombre" ("Nombre").
[warning] No translation found for "directorio.error" ("No se pudo cargar la lista de empleados").

Este fichero tiene el formato que produce ng extract-i18n --format json. Observa el orden del código: los mensajes se evalúan cuando se carga el módulo que los contiene. Un title: $localize... en las rutas se ejecuta al importar app.routes.ts. Si importas la aplicación con un import normal al principio de main.ts, los textos se resuelven antes de loadTranslations y salen sin traducir. Aquí no hay registro automático: LOCALE_ID y registerLocaleData vuelven a ser responsabilidad tuya.

Con esta técnica, cambiar de idioma también exige recargar, porque las plantillas se traducen una sola vez. Su ventaja es tener un solo despliegue. Para cambiar de idioma al instante, necesitas una biblioteca.


12. Qué es una PWA

Pasamos a la segunda parte del módulo. Una aplicación web progresiva (Progressive Web App, PWA) es una aplicación web que se comporta como una aplicación instalada: arranca sin red, se puede añadir al escritorio y puede recibir notificaciones. Sigue siendo una web. No pasa por una tienda, y quien abre el enlace con un navegador antiguo sigue viendo la web normal. Por eso se llama progresiva.

Se apoya en dos piezas:

Pieza Qué aporta
Service worker Un programa que intercepta las peticiones de la página y puede responder desde una caché
Manifest web Un JSON con el nombre, los iconos y la forma de mostrarse al instalarla

Para EMPRESA tiene sentido: quien visita un almacén con mala cobertura puede consultar la plantilla sin esperar y sin ver la página de error del navegador.

El service worker

Un service worker es un JavaScript que el navegador instala y ejecuta en un hilo aparte, sin acceso al DOM. Actúa como un servidor intermedio que vive en el navegador: todas las peticiones de la página pasan por él, incluida la del propio index.html. Y sigue instalado cuando cierras la pestaña, lo que le permite responder en la siguiente visita aunque no haya red.

Dentro del navegador, la página pregunta al service worker, este consulta Cache Storage y, si hace falta, sale a la red

Figura 6. El service worker se coloca entre la página y la red.

Se suele comparar con un portero: la página pide algo, y él decide si lo entrega de la estantería (la caché) o si va a buscarlo fuera. Escribir este programa a mano es delicado, porque un error puede dejar a los usuarios atrapados en una versión antigua. Angular trae uno ya hecho, ngsw-worker.js (Angular Service Worker), que se configura con un JSON y no se programa.

⚠️ Importante: Los service workers solo funcionan en contextos seguros: HTTPS, además de localhost y 127.0.0.1 para desarrollar. Si despliegas en HTTP o pruebas desde otro equipo con la IP de tu máquina, el navegador no los registrará, sin que la aplicación falle.


13. Añadir la PWA a Angular

ng add @angular/pwa
Fichero Cambio
package.json Añade @angular/service-worker
angular.json Añade "serviceWorker": "ngsw-config.json" a la configuración production
src/app/app.config.ts Añade provideServiceWorker
src/index.html Añade <link rel="manifest" href="manifest.webmanifest">
public/manifest.webmanifest Nuevo: datos de instalación
public/icons/ Nuevo: ocho iconos de 72 a 512 píxeles
ngsw-config.json Nuevo: configuración del service worker

Esta es la configuración, ya ordenada:

// src/app/app.config.ts
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideServiceWorker('ngsw-worker.js', {
      enabled: !isDevMode(),
      registrationStrategy: 'registerWhenStable:30000',
    }),
  ],
};

enabled: !isDevMode() lo desactiva durante el desarrollo. Tiene dos motivos: ng serve no genera el fichero ngsw.json que el service worker necesita, y una caché entre tú y tu código te haría ver versiones antiguas al recargar. Por eso las PWA se prueban siempre con una compilación de producción.

registrationStrategy decide cuándo se registra. Instalar el service worker implica descargar ficheros, y eso no debe competir con el arranque de la aplicación:

Estrategia Cuándo se registra
registerWhenStable:30000 (por defecto) Cuando la aplicación está estable, o a los 30 segundos si no lo consigue antes
registerImmediately Enseguida
registerWithDelay:5000 Al cabo de 5 segundos
Función que devuelve un Observable Con el primer valor que emita

💡 Consejo: Escribe siempre el tiempo en registerWhenStable. Sin él, el código compara la estabilidad con un temporizador de cero milisegundos, y el registro se hace enseguida en lugar de esperar.

Lo que produce ng build

Además de la aplicación, cada compilación incluye estos ficheros:

Fichero Función
ngsw-worker.js El service worker de Angular
ngsw.json Manifiesto generado: qué ficheros hay en esta versión y sus hashes
safety-worker.js Un service worker de emergencia que se desinstala a sí mismo
worker-basic.min.js Copia del anterior, por compatibilidad

ngsw.json es la clave de todo: lista cada fichero con el hash de su contenido. Cuando publicas una versión, el hash global cambia, y así el service worker sabe que hay novedades y qué ficheros debe descargar.

Probarlo en tu equipo

ng serve no vale, y ng build deja los ficheros en dist. Sirve esa carpeta con el servidor.mjs de la sección 10. Si tu aplicación no usa localize, cualquier servidor estático vale:

npx http-server dist/empresa/browser -p 8080

14. ngsw-config.json: los ficheros de la aplicación

Este es el fichero que genera ng add:

{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.csr.html",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
        ]
      }
    }
  ]
}

index indica qué fichero se entrega ante una navegación. Las rutas se escriben respecto a la carpeta de salida, sin pensar en base href: el generador las adapta. La entrada /index.csr.html es para aplicaciones con SSR, que viste en el módulo 24.

Un grupo de recursos (assetGroups) es un conjunto de ficheros que forman parte de la versión publicada. Tienen dos opciones clave:

Opción Valor Efecto
installMode prefetch Descarga todo el grupo al instalar la versión. Es lo que permite arrancar sin red
  lazy Guarda cada fichero la primera vez que alguien lo pide
updateMode prefetch Al llegar una versión, descarga los ficheros cambiados que ya tenía guardados
  lazy Espera a que se pidan

Por eso, el grupo app usa prefetch: sin main.js o sin index.html, nada funciona. El grupo assets usa lazy porque las imágenes pueden pesar mucho, y descargarlas todas castigaría a quien no va a verlas.

⚠️ Importante: Un fichero que el usuario no ha pedido y está en un grupo lazy no existirá sin conexión. Una imagen o una fuente imprescindible debe ir en prefetch. La carga perezosa del router no te afecta: los chunks son ficheros .js, así que entran en el grupo app y se descargan al instalar. En nuestra compilación, chunk-PZXAWPPY.js, el de departamentos, aparece en ese grupo.

El generador convierte esta configuración en el ngsw.json de cada compilación, y ahí ves exactamente qué se guarda. Este fragmento es real:

/en/index.html
app (prefetch): /en/chunk-C4DTFG27.js, /en/chunk-PZXAWPPY.js, /en/favicon.ico,
                /en/index.html, /en/main-NEODMM5K.js, /en/manifest.webmanifest,
                /en/polyfills-B6AWBRSW.js, /en/styles-YHE6ASLO.css
assets (lazy):  /en/icons/icon-128x128.png, /en/icons/icon-144x144.png, ...

Fíjate en el prefijo /en/: cada idioma tiene su ngsw.json. Lo retomaremos en la sección 20.

Si escribes /es/departamentos en la barra de direcciones, ese fichero no existe, pero el service worker lo reconoce como una navegación y entrega index.html. Es lo que permite recargar en cualquier ruta sin conexión.

Los valores por defecto de navigationUrls excluyen las URL que parecen ficheros (con un punto en el último segmento) y las que contienen __, que se usan a menudo para rutas internas de autenticación o del servidor. Si tienes rutas que debe atender el servidor, como /informes/**, añade una exclusión:

"navigationUrls": [
  "/**",
  "!/**/*.*",
  "!/**/*__*",
  "!/**/*__*/**",
  "!/informes/**"
]

15. dataGroups: las respuestas de la API

Con assetGroups, la aplicación arranca sin red, pero se queda sin datos. Para guardar respuestas de la API están los grupos de datos. Se diferencian de los anteriores en un punto esencial: no forman parte de la versión. Cuando publicas otra versión, los datos guardados se conservan y no se comparan con ningún hash, porque el generador no los conoce. Cada grupo decide cuánto vive una respuesta.

{
  "dataGroups": [
    {
      "name": "catalogos",
      "urls": ["http://localhost:3000/api/departamentos"],
      "cacheConfig": {
        "strategy": "performance",
        "maxSize": 10,
        "maxAge": "1h"
      }
    },
    {
      "name": "empleados",
      "urls": ["http://localhost:3000/api/empleados**"],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 30,
        "maxAge": "1d",
        "timeout": "3s"
      }
    }
  ]
}

Dos diagramas de decisión: performance mira primero la caché; freshness mira primero la red y usa la caché al agotarse el tiempo

Figura 7. Las dos estrategias.

Opción Significado
urls Patrones de URL. Escribe las de otro origen completas, con protocolo y puerto, para no coincidir por accidente
version Número del grupo. Súbelo si cambia el formato de la API, para descartar lo guardado
maxSize Máximo de respuestas guardadas. Al superarlo, expulsa la que lleva más tiempo sin usarse
maxAge Duración de una respuesta: 3d12h, 30m, 10s (d, h, m, s, u para milisegundos)
strategy performance (por defecto) o freshness
timeout Solo en freshness: tiempo de espera a la red antes de usar la caché
refreshAhead En performance, refresca en segundo plano una respuesta que está a punto de caducar
cacheOpaqueResponses Permite guardar respuestas opacas, de otro origen sin CORS
cacheQueryOptions { "ignoreSearch": true } ignora la cadena de consulta al buscar en la caché

📘 Concepto: Cada patrón de urls se convierte en una expresión regular sin anclar: basta con que aparezca en algún punto de la URL. El generador produce http:\/\/localhost:3000\/api\/departamentos, sin ^ ni $, de modo que también coincide con /api/departamentos/10 y con ?pagina=2. Nosotros lo hemos comprobado sobre el ngsw.json generado. El ** final del grupo empleados no cambia nada: solo deja claro que incluye sus búsquedas, porque httpResource añade siempre ?q=...&tamano=.... La URL completa, consulta incluida, es la clave de la caché, así que cada búsqueda es una entrada distinta.

Elegir es una decisión del negocio, y la pregunta correcta es qué es peor en cada pantalla: esperar o ver un dato antiguo.

  • performance responde desde la caché sin tocar la red hasta que caduca. Va muy bien para datos que cambian poco. Durante maxAge, un departamento nuevo no aparecerá.
  • freshness intenta primero la red, y solo tras timeout o un fallo usa la copia. Con la red lenta, cada petición tarda hasta 3 segundos. Si no hay red, la respuesta es inmediata, porque el fallo se produce enseguida.

Las escrituras no se guardan. Las peticiones GET y HEAD siguen la estrategia del grupo. Las demás, como POST o DELETE, van directas a la red, y además borran de la caché la entrada de esa misma URL exacta. Si haces DELETE /api/empleados/100, la lista /api/empleados?q= no se entera: seguirá guardada y puede mostrar un empleado borrado hasta que se refresque.

⚠️ Importante: Lo que guardas en dataGroups queda en el disco del usuario, fuera del control de tu sesión. Cerrar sesión no lo borra. No metas en estos grupos respuestas con datos personales o privados en una aplicación que se use en equipos compartidos, y no incluyas nunca las rutas de autenticación. Los salarios de nuestro ejemplo solo están en una demostración.

⚠️ Importante: Sin conexión, la cola de escrituras no existe. Un POST hecho sin red simplemente falla: el service worker de Angular no lo guarda para reintentarlo. Es tu aplicación quien debe mostrar el error o implementar una cola propia.


16. Probar el modo sin conexión

El panel Application de las herramientas de desarrollo tiene todo lo necesario: la pestaña Service workers muestra el estado y permite activar Offline, y Cache storage enseña lo guardado.

Hazlo en este orden, que es el que suele fallar:

  1. Compila y sirve: ng build y node servidor.mjs.
  2. Abre http://127.0.0.1:8080/es/. En esta primera visita, el service worker se registra, pero aún no controla la página.
  3. Recarga. Ahora sí pasa todo por él, y las peticiones a la API empiezan a guardarse.
  4. Visita las pantallas que quieras tener disponibles.
  5. Activa Offline en el panel, o detén el servidor y la API, y recarga.

Hemos automatizado esos pasos con Playwright, que verás en el módulo 26, usando context.setOffline(true). Así se ve la aplicación recargada sin red:

La aplicación con la insignia Sin conexión en rojo, y la tabla de empleados completa servida desde la caché

Figura 8. Página recargada sin red. El HTML, el JavaScript y los datos vienen del service worker.

Los resultados de la prueba fueron estos:

Sin conexión, el usuario… Resultado Por qué
Recarga /es/empleados Funciona, con 5 filas de 24 empleados Respuesta guardada por freshness
Va a Departamentos Funciona, con 10 departamentos Pantalla visitada antes, guardada por performance
Busca garcia por primera vez Muestra el mensaje de error Esa URL nunca se pidió y no hay copia
Abre /en/empleados Error del navegador ERR_INTERNET_DISCONNECTED El service worker de /es/ no controla /en/

La pantalla del directorio sin conexión con el mensaje No se pudo cargar la lista de empleados

Figura 9. Una búsqueda que nunca se hizo con red. La aplicación sigue viva y puede explicar lo que ocurre.

La tercera fila es muy instructiva. La aplicación sin conexión no es la aplicación con datos: solo tienes lo ya visitado. Hay que diseñar las pantallas pensando en el estado de error, y aquí es donde brillan los recursos del módulo 13. Sin el @if (lista.error()) de esta plantilla, la aplicación mostraría una pantalla vacía:

<!-- src/app/directorio/directorio.html (fragmento) -->
@if (lista.error()) {
  <p class="error">{{ mensajeError }}</p>
} @else if (lista.hasValue()) {
  @let total = lista.value().total;

  <p class="total" i18n="@@directorio.total">
    {total, plural,
      =0 {No hay empleados}
      =1 {Hay un empleado}
      other {Hay {{ total }} empleados}
    }
  </p>
  <!-- tabla -->
}
// src/app/directorio/directorio.ts
import { CurrencyPipe, DatePipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, signal } from '@angular/core';
import { API } from '../api';
import { Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-directorio',
  imports: [CurrencyPipe, DatePipe],
  templateUrl: './directorio.html',
})
export class Directorio {
  protected readonly empresa = 'EMPRESA';
  protected readonly hoy = new Date(2026, 10, 2);
  protected readonly busqueda = signal('');

  protected readonly lista = httpResource<Pagina<Empleado>>(() => ({
    url: `${API}/empleados`,
    params: { q: this.busqueda(), tamano: 5, orden: 'apellidos' },
  }));

  protected readonly mensajeError = $localize`:Aviso cuando falla la API@@directorio.error:No se pudo cargar la lista de empleados`;

  protected buscar(evento: Event) {
    this.busqueda.set((evento.target as HTMLInputElement).value);
  }
}

Detectar la conexión con una señal 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de @Service(), escribe @Injectable({ providedIn: 'root' }), importado de @angular/core; todo lo demás funciona igual.

El navegador expone navigator.onLine y dos eventos, online y offline. Convertirlos en señal, como en el módulo 08, permite usarlos en cualquier plantilla:

// src/app/pwa/conexion.ts
import { DestroyRef, Service, inject, signal } from '@angular/core';

@Service()
export class Conexion {
  private readonly estado = signal(navigator.onLine);
  readonly enLinea = this.estado.asReadonly();

  constructor() {
    const actualizar = () => this.estado.set(navigator.onLine);
    window.addEventListener('online', actualizar);
    window.addEventListener('offline', actualizar);

    inject(DestroyRef).onDestroy(() => {
      window.removeEventListener('online', actualizar);
      window.removeEventListener('offline', actualizar);
    });
  }
}

⚠️ Importante: navigator.onLine solo indica si el equipo tiene alguna red. Con wifi conectada a un router sin Internet, dice true. Úsalo para informar al usuario, no para decidir si haces una petición: la única prueba real de que hay conexión es que la petición funcione.

Depurar el service worker

El service worker responde a una URL especial, ngsw/state, que no llega al servidor. Se abre dentro de su ámbito, en nuestro caso http://127.0.0.1:8080/es/ngsw/state:

NGSW Debug Info:

Driver version: 22.2.4
Driver state: NORMAL ((nominal))
Latest manifest hash: 0d25feed989b63448a162eabe3c755f181682631
Last update check: 3s880u

=== Version 0d25feed989b63448a162eabe3c755f181682631 ===

Clients: 6aca447c-0109-43a3-8967-baecbcdec22a

=== Idle Task Queue ===
Last update tick: 1s626u
Last update run: never

Muestra el hash de la versión activa, qué pestañas (clients) la usan y las tareas pendientes. Driver state es lo primero que debes mirar. Los otros valores posibles son EXISTING_CLIENTS_ONLY y SAFE_MODE, e indican que el service worker se ha degradado por un error, con el motivo entre paréntesis. Más abajo, la sección Debug log recoge los últimos errores.

Las cachés también se pueden listar en la consola con await caches.keys(). Sus nombres revelan toda la organización:

ngsw:/es/:db:control
ngsw:/es/:0d25feed989b63448a162eabe3c755f181682631:assets:app:cache
ngsw:/es/:0d25feed989b63448a162eabe3c755f181682631:assets:assets:cache
ngsw:/es/:1:data:catalogos:cache
ngsw:/es/:1:data:empleados:cache

Los ficheros van por hash de versión. Los datos van por grupo y por su campo version, que es el 1 del nombre. Por eso sobreviven a un despliegue.

Saltarse el service worker

A veces una petición concreta debe ir siempre a la red. El service worker la deja pasar si la URL lleva el parámetro ngsw-bypass, o si la petición lleva una cabecera con ese nombre. Con una API de otro origen, usa el parámetro: una cabecera propia provoca una comprobación CORS, y la API tendría que permitirla.

this.http.get(`${API}/empleados`, { params: { q: 'marta', 'ngsw-bypass': 'true' } });

17. Versiones nuevas con SwUpdate

Aquí está la gran diferencia con una web normal, y su principal trampa. Sin service worker, recargar trae lo último. Con él, recargar entrega la versión guardada, porque la página se sirve antes de preguntar al servidor. Mientras tanto, el service worker comprueba en segundo plano si hay novedades, y las descarga. Si no haces nada, el usuario verá la versión nueva en la visita siguiente.

Diagrama de secuencia entre página, service worker y servidor: pide ngsw.json, recibe otro hash, descarga, avisa con VERSION_READY, activa y recarga

Figura 10. Cómo llega una versión nueva.

La comprobación ocurre al iniciarse el service worker, poco después de una navegación. SwUpdate te permite enterarte y actuar:

Miembro Qué hace
isEnabled true si hay service worker activo. Es false en ng serve
versionUpdates Observable de eventos del ciclo de versiones
unrecoverable Observable que avisa de un estado irrecuperable
checkForUpdate() Comprueba ahora. La promesa da true si hay una versión nueva lista
activateUpdate() Asigna a esta pestaña la última versión. La promesa da true si cambió
Evento de versionUpdates Significado
VERSION_DETECTED Hay una versión nueva y empieza a descargarse
VERSION_READY Ya está descargada entera, con currentVersion y latestVersion
VERSION_INSTALLATION_FAILED Falló la descarga. Incluye error
NO_NEW_VERSION_DETECTED Se ha comprobado y no hay nada nuevo

Un servicio que avisa al usuario 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de @Service(), escribe @Injectable({ providedIn: 'root' }); SwUpdate funciona exactamente igual.

// src/app/pwa/actualizaciones.ts
import { ApplicationRef, DOCUMENT, Service, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SwUpdate, VersionEvent } from '@angular/service-worker';

const SEIS_HORAS = 6 * 60 * 60 * 1000;

@Service()
export class Actualizaciones {
  private readonly sw = inject(SwUpdate);
  private readonly appRef = inject(ApplicationRef);
  private readonly documento = inject(DOCUMENT);

  private readonly nueva = signal(false);
  private readonly rota = signal(false);
  readonly hayVersionNueva = this.nueva.asReadonly();
  readonly versionRota = this.rota.asReadonly();

  constructor() {
    if (!this.sw.isEnabled) {
      return; // en ng serve, o en un navegador sin service worker
    }

    this.sw.versionUpdates
      .pipe(takeUntilDestroyed())
      .subscribe((evento) => this.alRecibir(evento));

    this.sw.unrecoverable.pipe(takeUntilDestroyed()).subscribe(() => this.rota.set(true));

    void this.comprobarPeriodicamente();
  }

  private alRecibir(evento: VersionEvent) {
    switch (evento.type) {
      case 'VERSION_DETECTED':
        console.log('Descargando la versión', evento.version.hash);
        break;
      case 'VERSION_READY':
        console.log('Lista la versión', evento.latestVersion.hash);
        this.nueva.set(true);
        break;
      case 'VERSION_INSTALLATION_FAILED':
        console.error('Falló la versión', evento.version.hash, evento.error);
        break;
      case 'NO_NEW_VERSION_DETECTED':
        break;
    }
  }

  private async comprobarPeriodicamente() {
    await this.appRef.whenStable();
    setInterval(() => void this.sw.checkForUpdate(), SEIS_HORAS);
  }

  async aplicar() {
    await this.sw.activateUpdate();
    this.documento.location.reload();
  }
}

Tres detalles importantes:

  • VERSION_READY y no VERSION_DETECTED. Avisar antes de que acabe la descarga llevaría al usuario a recargar en una versión a medias.
  • whenStable antes del temporizador. comprobarPeriodicamente espera a que termine el arranque, para no interferir con el registro del service worker, ligado también a la estabilidad.
  • activateUpdate más reload. Después de activar, la página ejecuta el JavaScript antiguo, pero los ficheros se sirven de la versión nueva. Un chunk perezoso que aún no se hubiera cargado ya no existiría. Recargar lo deja todo coherente.

Lo hemos comprobado con dos versiones reales. Con la primera instalada y el service worker controlando la página, publicamos en el servidor la segunda, en la que el título termina en v2, y recargamos:

v1: Directorio de empleados
tras publicar y recargar: Directorio de empleados
  consola: Descargando la versión 51426e7a9b9bf7561066c5a88069a18d50030270
  consola: Lista la versión 51426e7a9b9bf7561066c5a88069a18d50030270
aviso a los 6248 ms
tras pulsar Actualizar: Directorio de empleados v2

La recarga sigue mostrando el título antiguo, que es justo la trampa de la que hablábamos. El aviso aparece unos segundos después, porque el service worker espera a que la actividad de la página se calme antes de comprobar. En ese momento, ngsw/state lista las dos versiones a la vez, con la pestaña todavía asignada a la antigua.

La aplicación con una franja de aviso: Hay una versión nueva de la aplicación, y un botón Actualizar

Figura 11. Aviso real tras detectar la versión nueva. Hasta que no se pulsa el botón, el usuario sigue en la versión instalada.

💡 Consejo: Si no quieres interrumpir, deja el aviso discreto y aplica la versión sola en la siguiente visita, que es lo que ocurre sin hacer nada. Evita recargar sin preguntar: el usuario podría estar rellenando un formulario.

Estados irrecuperables

A veces la versión que usa una pestaña ya no se puede completar. Suele ocurrir si el navegador borró parte de la caché por falta de espacio y, cuando el service worker va a recuperar un fichero, el servidor ya no lo tiene, porque desplegaste otra versión. La aplicación no puede seguir, y unrecoverable emite un evento con el campo reason. Solo cabe avisar y recargar, que es lo que hace el primer @if de app.html.

Lo provocamos sin querer al preparar este módulo: sustituimos la carpeta del servidor mientras el service worker aún estaba descargando la primera versión. Recibió un 404 en /es/index.html, descartó esa versión, y en el registro de ngsw/state apareció Failed to retrieve hashed resource from the server. Ocurre igual en producción si un despliegue borra los ficheros antiguos mientras hay usuarios instalando. Por eso conviene publicar los ficheros nuevos primero, y dejar ngsw.json para el final.

Retirar un service worker

Un service worker defectuoso no se arregla borrando el fichero, porque los usuarios siguen teniéndolo instalado. Si necesitas desactivarlo, publica safety-worker.js con el nombre ngsw-worker.js, en la misma ruta. El navegador detecta el cambio, instala este sustituto, y el sustituto se da de baja a sí mismo y borra las cachés. Es tu salida de emergencia, y viene incluida en cada compilación.

⚠️ Importante: Sirve ngsw.json y index.html sin caché larga en el servidor. Si una CDN los guarda un día, tus usuarios tardarán un día en descubrir las versiones nuevas. El servidor.mjs envía Cache-Control: no-cache.


18. El manifest web

public/manifest.webmanifest describe la aplicación para cuando se instala. Esta es la nuestra, ya adaptada:

{
  "name": "Gestión EMPRESA",
  "short_name": "EMPRESA",
  "description": "Directorio de empleados y departamentos",
  "lang": "es",
  "display": "standalone",
  "scope": "./",
  "start_url": "./",
  "theme_color": "#283593",
  "background_color": "#ffffff",
  "icons": [
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ]
}
Campo Qué controla
name y short_name Nombre completo, y nombre corto bajo el icono
display standalone abre sin barra de direcciones. También fullscreen, minimal-ui y browser
start_url Con qué URL arranca. Relativa al propio manifest
scope Qué URL pertenecen a la aplicación instalada
theme_color y background_color Color de la barra del sistema, y fondo de la pantalla de arranque
icons Iconos. purpose: maskable admite recorte con la forma del sistema

Los iconos generados llevan el logotipo de Angular: sustitúyelos por los tuyos conservando los nombres. Las rutas relativas son una buena elección, porque el mismo manifest funciona en /es/, /en/ y /fr/.

Chrome confirma que cumple los requisitos. Al consultar la instalabilidad de nuestra aplicación mediante su protocolo de depuración, Page.getInstallabilityErrors devuelve una lista vacía: {"installabilityErrors":[]}.

Con el manifest y el service worker en HTTPS, el navegador ofrecerá instalar la aplicación. Cómo lo hace depende de cada uno. Los navegadores basados en Chromium muestran un icono en la barra de direcciones y emiten el evento beforeinstallprompt, con el que puedes poner tu propio botón, como propone el ejercicio 8. Safari y Firefox tienen sus propios caminos, y ese evento no forma parte de ningún estándar: trátalo siempre como una mejora opcional.


19. Notificaciones push con SwPush 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: en lugar de @Service(), escribe @Injectable({ providedIn: 'root' }); SwPush funciona igual.

El service worker sigue vivo con la aplicación cerrada, así que puede recibir mensajes del servidor y mostrar notificaciones. Hace falta una pieza que EMPRESA no tiene: un servidor que guarde las suscripciones y envíe los mensajes a través del servicio push del navegador, con un par de claves VAPID.

// src/app/pwa/avisos.ts
import { HttpClient } from '@angular/common/http';
import { Service, inject } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { firstValueFrom } from 'rxjs';
import { API } from '../api';

@Service()
export class Avisos {
  private readonly push = inject(SwPush);
  private readonly http = inject(HttpClient);

  readonly mensajes = this.push.messages;
  readonly clics = this.push.notificationClicks;

  async suscribirse(clavePublica: string) {
    if (!this.push.isEnabled) {
      return;
    }
    const suscripcion = await this.push.requestSubscription({ serverPublicKey: clavePublica });
    await firstValueFrom(this.http.post(`${API}/suscripciones`, suscripcion));
  }
}

requestSubscription muestra el permiso del navegador y devuelve una PushSubscription, que hay que enviar al servidor. La ruta /suscripciones no existe en la API EMPRESA: este código compila, pero no se ha probado con un servidor push real, porque requiere uno. El service worker solo muestra una notificación si el mensaje tiene un objeto notification con title:

{
  "notification": {
    "title": "Pedido 5001 enviado",
    "body": "Ha salido del almacén de Madrid"
  }
}

💡 Consejo: Pide el permiso cuando el usuario haga algo que lo justifique, por ejemplo pulsar «Avisarme», y nunca al abrir la página. Un rechazo es difícil de revertir, porque debe cambiarlo el usuario en los ajustes del navegador.


20. Los dos temas juntos

Combinar localize y serviceWorker funciona sin configuración extra, y el resultado merece una explicación. Cada carpeta de idioma recibe sus propios ficheros:

browser/
  en/  index.html  main-NEODMM5K.js  manifest.webmanifest  ngsw.json  ngsw-worker.js  ...
  es/  index.html  main-NEODMM5K.js  manifest.webmanifest  ngsw.json  ngsw-worker.js  ...
  fr/  index.html  main-NEODMM5K.js  manifest.webmanifest  ngsw.json  ngsw-worker.js  ...

provideServiceWorker('ngsw-worker.js') usa una ruta relativa, que se resuelve desde base href. Así, en /en/ se registra /en/ngsw-worker.js. Un service worker solo controla las URL que cuelgan de su carpeta (su ámbito, scope), de modo que cada idioma tiene su propio service worker, con sus cachés y sus datos. Son independientes, y de ahí salen varias consecuencias:

  • Quien solo usa /es/ no descarga nada de los demás idiomas. Es exactamente lo que queremos.
  • Si no ha visitado /en/, ese idioma no existe sin conexión, como comprobó la última fila de la tabla de la sección 16.
  • Las cachés no se comparten: la lista de empleados se guarda aparte en cada idioma.
  • Al cambiar de idioma por primera vez, el navegador registra otro service worker.

El manifest es el mismo fichero para todos, porque public/ se copia sin traducir. name queda en español en las tres versiones, aunque start_url sí apunta a cada idioma, al ser relativa. Si necesitas nombres traducidos, usa un nombre neutro, como una marca, o genera un manifest por idioma con un script tras la compilación.

💡 Consejo: Si tu aplicación usa SSR, añade withI18nSupport() a provideClientHydration para que la hidratación incluya los bloques i18n, como complemento de lo que viste en el módulo 24.

Errores frecuentes

Síntoma Causa Solución
Cannot find name '$localize' No se ha ejecutado ng add @angular/localize Ejecútalo; añade los tipos y el polyfill
Sale $localize is not defined en el navegador Falta @angular/localize/init en polyfills Añádelo en angular.json
La pantalla en blanco en /en/, con errores 404 de main-...js El servidor no entrega la carpeta de ese idioma o base href es incorrecto Revisa subPath y la regla del servidor
/en/departamentos da 404 al recargar El servidor no devuelve el index.html del idioma Usa try_files $uri /$1/index.html o equivalente
Se mezclan idiomas en una pantalla Unidades sin <target> o textos sin marcar Revisa los ficheros, y busca aria-label, title y ternarios
Los textos de rutas quedan en el idioma original con loadTranslations La aplicación se importó antes de cargar las traducciones Usa import() dinámico tras loadTranslations
SwUpdate.isEnabled es false Estás en ng serve, sin HTTPS o el navegador no lo admite Prueba con ng build y un servidor
Al desplegar, nadie ve la versión nueva ngsw.json en caché larga, o no hay aviso no-cache para ngsw.json, y usa SwUpdate
Primera visita sin datos sin conexión El service worker aún no controlaba la página Recarga una vez con red
Una imagen falla sin conexión Grupo lazy, nunca solicitada Pásala a prefetch
La lista sigue mostrando un dato borrado performance y entrada que no se invalida Usa freshness o reduce maxAge
La API no se guarda Otro puerto u origen en urls, o se prueba la primera visita Compara urls con la URL real y recarga con red

21. Ejercicios

Ejercicio 1: Traducir el pie de página

Crea un componente Pie con un aviso legal que incluya el año en una interpolación, y un enlace «Privacidad» con un title. Márcalo todo para traducir, con identificadores propios y una descripción para el aviso legal.

Ver solución
import { Component } from '@angular/core';

@Component({
  selector: 'app-pie',
  template: `
    <footer>
      <p i18n="Pie de página|Aviso legal bajo cada pantalla@@pie.legal">
        © {{ anio }} EMPRESA S.A. Todos los derechos reservados.
      </p>
      <a
        href="/privacidad"
        title="Lee cómo tratamos tus datos"
        i18n-title="@@pie.privacidad.ayuda"
        i18n="Enlace del pie@@pie.privacidad"
      >Privacidad</a>
    </footer>
  `,
})
export class Pie {
  protected readonly anio = 2026;
}

El enlace necesita dos marcas: i18n para su contenido e i18n-title para el atributo. Son dos unidades distintas. La frase del aviso se marca entera, con la interpolación dentro, para que cada idioma coloque el año donde le corresponda.

Ejercicio 2: Plural y select anidados

Crea un componente ResumenPedidos que reciba pendientes, y muestre «Sin pedidos pendientes», «Tienes un pedido pendiente» o «Tienes 7 pedidos pendientes». Añade otra línea que reciba genero y personas, y concuerde: «Una directora», «2 directoras», «Un director», «3 directores», y «Una persona» o «5 personas» si no se indica género.

Ver solución
import { Component, input } from '@angular/core';

@Component({
  selector: 'app-resumen-pedidos',
  template: `
    <p i18n="@@pedidos.resumen">
      {pendientes(), plural,
        =0 {Sin pedidos pendientes}
        =1 {Tienes un pedido pendiente}
        other {Tienes {{ pendientes() }} pedidos pendientes}
      }
    </p>
    <p i18n="@@equipo.resumen">
      {genero(), select,
        mujer {{personas(), plural, =1 {Una directora} other {{{ personas() }} directoras}}}
        hombre {{personas(), plural, =1 {Un director} other {{{ personas() }} directores}}}
        other {{personas(), plural, =1 {Una persona} other {{{ personas() }} personas}}}
      }
    </p>
  `,
})
export class ResumenPedidos {
  readonly pendientes = input.required<number>();
  readonly genero = input<string>('otro');
  readonly personas = input(1);
}

Salida real con [pendientes] a 0, 1 y 7:

Sin pedidos pendientes         Una persona
Tienes un pedido pendiente     2 directoras
Tienes 7 pedidos pendientes    Un director

La segunda fila usa genero="mujer" y [personas]="2", y la tercera, genero="hombre".

Las llaves triples de {{{ personas() }} directoras} no son una sintaxis especial: la primera abre el caso y las dos siguientes abren la interpolación.

Ejercicio 3: Predecir el plural

Sin ejecutar nada, responde: ¿qué muestra esta unidad en francés con total igual a 0, 1 y 2? Si borraras el caso =0, ¿qué se vería con 0? Comprueba después tu respuesta.

{total, plural, =0 {Aucun employé} one {{{total}} employé} other {{{total}} employés}}
Ver solución

Con 0 muestra «Aucun employé», porque los casos exactos tienen prioridad sobre las categorías. Con 1 muestra «1 employé». Con 2, «2 employés».

Sin el caso =0, se vería «0 employé», en singular y sin ningún error, porque en francés new Intl.PluralRules('fr').select(0) devuelve one. Es correcto en francés, y con la misma regla en español la salida sería other, es decir, «0 empleados».

Ejercicio 4: Cifras compactas

Crea un pipe compacta que formatee números grandes de forma abreviada según LOCALE_ID, sin registrar datos adicionales. Pruébalo con 1234567 en los tres idiomas, junto con 1234.5 | currency, usando el euro como moneda por defecto con DEFAULT_CURRENCY_CODE.

Ver solución
import { CurrencyPipe } from '@angular/common';
import { Component, DEFAULT_CURRENCY_CODE, LOCALE_ID, Pipe, PipeTransform, inject } from '@angular/core';

@Pipe({ name: 'compacta' })
export class CompactaPipe implements PipeTransform {
  private readonly formato = new Intl.NumberFormat(inject(LOCALE_ID), {
    notation: 'compact',
    maximumFractionDigits: 1,
  });

  transform(valor: number): string {
    return this.formato.format(valor);
  }
}

@Component({
  selector: 'app-cifras',
  imports: [CompactaPipe, CurrencyPipe],
  providers: [{ provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' }],
  template: `<p>{{ 1234567 | compacta }} / {{ 1234.5 | currency }} / {{ 1234.5 | currency: 'USD' }}</p>`,
})
export class Cifras {}

Salida real en cada idioma:

es: 1,2 M / 1.234,50 € / 1.234,50 US$
en: 1.2M / €1,234.50 / $1,234.50
fr: 1,2 M / 1 234,50 € / 1 234,50 $US

Intl no necesita datos de Angular, porque los trae el navegador. Crear el formato en un campo evita construirlo por cada valor: Intl.NumberFormat es relativamente costoso. Sin DEFAULT_CURRENCY_CODE, currency usaría USD, sea cual sea el idioma.

Ejercicio 5: Detectar lo que falta antes de desplegar

Haz que la integración continua falle si alguien añade un texto y olvida traducirlo. Indica qué cambias en angular.json, y explica qué caso no detecta este método.

Ver solución
"options": {
  "i18nMissingTranslation": "error"
}

Si el texto nuevo se marcó con i18n y su unidad no existe en messages.fr.xlf, ng build falla. Hay dos casos que no detecta:

  • Una unidad copiada que existe sin <target>: se acepta y sale el texto original, como vimos en la sección 7.
  • Un texto que nadie marcó con i18n. Ninguna herramienta sabe que debería traducirse.

Para el primero, puedes añadir una comprobación propia que busque unidades sin <target>. Para el segundo, nada sustituye a revisar la aplicación en cada idioma.

Ejercicio 6: Un grupo para los productos

Añade a ngsw-config.json un grupo que guarde durante 12 horas las respuestas del catálogo de productos con sus filtros, hasta 40 respuestas, primero desde la caché, y que las refresque en segundo plano cuando les queden menos de 2 horas.

Ver solución
{
  "name": "productos",
  "urls": ["http://localhost:3000/api/productos**"],
  "version": 1,
  "cacheConfig": {
    "strategy": "performance",
    "maxSize": 40,
    "maxAge": "12h",
    "refreshAhead": "2h"
  }
}

Lo hemos validado con el generador: maxAge queda en 43 200 000 ms y refreshAheadMs en 7 200 000. El patrón resultante, http:\/\/localhost:3000\/api\/productos[^/]*[^/]*, no está anclado, así que coincide con /api/productos, con /api/productos?disponibles=true y también con /api/productos/1. Si no quieres guardar el detalle, añade la URL a otro grupo anterior: gana el primer grupo que coincide.

Ejercicio 7: Una petición que nunca sale de la caché

La pantalla de saldos necesita un dato que nunca debe servirse desde una copia, aunque su URL coincida con un dataGroup. Haz una petición a la lista de empleados que se salte el service worker, y comprueba qué ocurre con y sin conexión.

Ver solución
protected sinCache() {
  this.http
    .get(`${API}/empleados`, { params: { q: 'marta', 'ngsw-bypass': 'true' } })
    .subscribe({ next: () => this.resultado.set('ok'), error: () => this.resultado.set('fallo') });
}

Resultado real: con red, la petición funciona, y al listar la caché ngsw:/es/:1:data:empleados:cache solo aparecen las búsquedas normales, sin la de marta. Sin red, la petición falla y el error deja fallo en resultado, aunque otras URL del mismo grupo sigan respondiendo desde la caché.

No sirve la cabecera ngsw-bypass con esta API, porque se trata de otro origen: el navegador haría una comprobación CORS, y la API solo permite Content-Type y Authorization.

Ejercicio 8 (propuesto): Botón para instalar

Crea un servicio Instalacion que capture beforeinstallprompt, cancele el aviso por defecto con preventDefault, y exponga una señal sePuedeInstalar. Añade un botón que llame a prompt() y oculte el botón cuando se reciba appinstalled. TypeScript no incluye el tipo de ese evento: declara tú una interfaz con prompt(). Comprueba que en un navegador sin este evento la aplicación sigue funcionando.

Ejercicio 9 (propuesto): Aviso de datos guardados

Usa Conexion para mostrar una franja traducida en la pantalla de empleados: «Estás viendo datos guardados», cuando enLinea() sea false. Haz además que desaparezca sola al volver la conexión, recargando el recurso con reload().

Ejercicio 10 (propuesto): Recordar el idioma elegido

Al pulsar un enlace de idioma, guarda la elección en una cookie idioma con Path=/. Modifica servidor.mjs para que, en la redirección de /, la cookie tenga prioridad sobre Accept-Language. Comprueba que un navegador en francés que eligió English entra en /en/.


Resumen

Concepto API Idea clave
Instalación i18n ng add @angular/localize Añade $localize como polyfill, tipos y dependencia
Idiomas i18n.sourceLocale, i18n.locales En angular.json. Declara siempre el idioma original
Marcar textos i18n="significado\|descripción@@id" Frases completas, con identificador propio
Marcar atributos i18n-placeholder, i18n-title Incluye aria-label y alt
Textos en código $localize`:@@id:texto` Solo literales. Los datos no se traducen
Plural {n, plural, =0 {..} one {..} other {..}} Las categorías dependen del idioma
Selección {v, select, a {..} other {..}} Las claves son valores, no textos
Extracción ng extract-i18n Compila y regenera solo messages.xlf
Textos que faltan i18nMissingTranslation error en producción. No detecta unidades sin <target>
Compilación "localize": true Una carpeta completa por idioma
Rutas subPath Carpeta y base href a la vez
Idioma actual LOCALE_ID Lo configura la compilación; no decide la moneda
Traducción en ejecución loadTranslations Una vez antes de arrancar. Para cambio instantáneo, una biblioteca
PWA ng add @angular/pwa Service worker, manifest e iconos
Registro provideServiceWorker Solo en producción y en contexto seguro
Ficheros assetGroups, installMode prefetch para arrancar sin red; lazy para el resto
Datos dataGroups, strategy performance primero caché, freshness primero red
Depuración ngsw/state, caches.keys() Muestra la versión, las pestañas y las cachés
Saltar la caché ngsw-bypass Como parámetro con APIs de otro origen
Versiones SwUpdate, VERSION_READY Avisa, activateUpdate y recarga
Estado roto unrecoverable Avisa y recarga
Emergencia safety-worker.js Publícalo como ngsw-worker.js para retirar el worker
Push SwPush Requiere servidor y claves VAPID
Idiomas y PWA Ámbito por carpeta Un service worker independiente por idioma