1. Antes de comenzar
Las aplicaciones web progresivas (AWP) son un tipo de software de aplicación que se entrega a través de la Web y se compila con tecnologías web comunes, como HTML, CSS y JavaScript. Están diseñados para funcionar en cualquier plataforma que use un navegador compatible con los estándares.
En este codelab, comenzarás con un AWP básico y, luego, explorarás nuevas capacidades del navegador que, con el tiempo, le darán superpoderes a tu AWP 🦸.
Muchas de estas nuevas capacidades del navegador están en desarrollo y aún se están estandarizando, por lo que, a veces, deberás establecer marcas del navegador para usarlas.
Requisitos previos
Para este codelab, debes tener conocimientos sobre JavaScript moderno, específicamente sobre promesas y async/await. Dado que no todos los pasos del codelab son compatibles con todas las plataformas, es útil para las pruebas tener dispositivos adicionales a mano, por ejemplo, un teléfono Android o una laptop que use un sistema operativo diferente del dispositivo en el que editas el código. Como alternativa a los dispositivos reales, puedes intentar usar simuladores, como el simulador de Android, o servicios en línea, como BrowserStack, que te permiten realizar pruebas desde tu dispositivo actual. De lo contrario, también puedes omitir cualquier paso, ya que no dependen entre sí.
Qué compilarás
Crearás una app web de tarjetas de felicitación y aprenderás cómo las capacidades nuevas y futuras del navegador pueden mejorar tu app para que brinde una experiencia avanzada en ciertos navegadores (pero siga siendo útil en todos los navegadores modernos).
Aprenderás a agregar capacidades de asistencia, como acceso al sistema de archivos, acceso al portapapeles del sistema, recuperación de contactos, sincronización periódica en segundo plano, bloqueo de activación de pantalla, funciones para compartir y mucho más.
Después de completar el codelab, comprenderás bien cómo mejorar progresivamente tus apps web con nuevas funciones del navegador, sin sobrecargar con descargas al subconjunto de usuarios que usan navegadores incompatibles y, lo que es más importante, sin excluirlos de tu app.
Requisitos
Por el momento, los navegadores totalmente compatibles son los siguientes:
Se recomienda usar el canal para desarrolladores en particular.
2. Project Fugu
Las apps web progresivas (AWP) se compilan y mejoran con APIs modernas para ofrecer capacidades, confiabilidad y capacidad de instalación mejoradas, y llegar a cualquier persona en la Web, en cualquier lugar del mundo y con cualquier tipo de dispositivo.
Algunas de estas APIs son muy potentes y, si no se controlan correctamente, las cosas pueden salir mal. Al igual que el pez globo 🐡: Cuando lo cortas bien, es un manjar, pero cuando lo cortas mal, puede ser letal (pero no te preocupes, nada puede romperse en este codelab).
Por eso, el nombre interno del proyecto de capacidades web (en el que las empresas involucradas desarrollan estas nuevas APIs) es Project Fugu.
Las capacidades web, incluso hoy en día, permiten que las empresas grandes y pequeñas creen soluciones basadas exclusivamente en el navegador, lo que, a menudo, permite una implementación más rápida con costos de desarrollo más bajos en comparación con la ruta específica de la plataforma.
3. Comenzar
Descarga cualquiera de los navegadores y, luego, configura la siguiente marca de tiempo de ejecución 🚩. Para ello, navega a about://flags
, que funciona tanto en Chrome como en Edge:
#enable-experimental-web-platform-features
Después de habilitarla, reinicia el navegador.
Usarás la plataforma Glitch, ya que te permite alojar tu APW y tiene un editor decente. Glitch también admite la importación y exportación a GitHub, por lo que no hay dependencia de un solo proveedor. Navega a fugu-paint.glitch.me para probar la aplicación. Es una app de dibujo básica 🎨 que mejorarás durante el codelab.
Después de probar la aplicación, remezcla la app para crear tu propia copia que puedas editar. La URL de tu remix se verá similar a glitch.com/edit/#!/bouncy-candytuft ("bouncy-candytuft" será otra cosa para ti). Se puede acceder a este remix directamente en todo el mundo. Accede a tu cuenta existente o crea una nueva en Glitch para guardar tu trabajo. Para ver tu app, haz clic en el botón "🕶 Mostrar". La URL de la app alojada será similar a bouncy-candytuft.glitch.me (ten en cuenta que se usa .me
en lugar de .com
como dominio de nivel superior).
Ahora puedes editar y mejorar tu app. Cada vez que realices cambios, la app se volverá a cargar y los cambios se verán directamente.
Lo ideal sería que las siguientes tareas se completaran en orden, pero, como se mencionó anteriormente, siempre puedes omitir un paso si no tienes acceso a un dispositivo compatible. Recuerda que cada tarea está marcada con 🐟, un pez de agua dulce inofensivo, o 🐡, un pez fugu que se debe manipular con cuidado, para alertarte sobre qué tan experimental es una función.
Consulta la consola en las Herramientas para desarrolladores para ver si una API es compatible con el dispositivo actual. También usamos Glitch para que puedas verificar la misma app en diferentes dispositivos con facilidad, por ejemplo, en tu teléfono celular y tu computadora de escritorio.
4. 🐟 Se agregó compatibilidad con la API de Web Share
Crear los dibujos más increíbles es aburrido si no hay nadie que los aprecie. Agrega una función que permita a los usuarios compartir sus dibujos con el mundo en forma de tarjetas de felicitación.
La API de Web Share admite el uso compartido de archivos y, como tal vez recuerdes, un File
es solo un tipo específico de Blob
. Por lo tanto, en el archivo llamado share.mjs
, importa el botón de compartir y una función de conveniencia toBlob()
que convierte el contenido de un lienzo en un blob y agrega la funcionalidad de compartir según el siguiente código.
Si implementaste esta función, pero no ves el botón, es porque tu navegador no implementa la API de Web Share.
import { shareButton, toBlob } from './script.mjs';
const share = async (title, text, blob) => {
const data = {
files: [
new File([blob], 'fugu-greeting.png', {
type: blob.type,
}),
],
title: title,
text: text,
};
try {
if (!navigator.canShare(data)) {
throw new Error("Can't share data.", data);
}
await navigator.share(data);
} catch (err) {
console.error(err.name, err.message);
}
};
shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});
5. 🐟 Se agregó compatibilidad con la API de Web Share Target
Ahora tus usuarios pueden compartir tarjetas de felicitación creadas con la app, pero también puedes permitir que compartan imágenes en tu app y las conviertan en tarjetas de felicitación. Para ello, puedes usar la API de Web Share Target.
En el manifiesto de la aplicación web, debes indicarle a la app qué tipo de archivos puede aceptar y qué URL debe llamar el navegador cuando se comparten uno o varios archivos. En el siguiente fragmento del archivo manifest.webmanifest
, se muestra esto.
{
"share_target": {
"action": "./share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image",
"accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
}
]
}
}
}
Luego, el trabajador de servicio se encarga de los archivos recibidos. La URL ./share-target/
no existe en realidad, la app solo actúa en el controlador fetch
y redirecciona la solicitud a la URL raíz agregando un parámetro de consulta ?share-target
:
self.addEventListener('fetch', (fetchEvent) => {
/* 🐡 Start Web Share Target */
if (
fetchEvent.request.url.endsWith('/share-target/') &&
fetchEvent.request.method === 'POST'
) {
return fetchEvent.respondWith(
(async () => {
const formData = await fetchEvent.request.formData();
const image = formData.get('image');
const keys = await caches.keys();
const mediaCache = await caches.open(
keys.filter((key) => key.startsWith('media'))[0],
);
await mediaCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})(),
);
}
/* 🐡 End Web Share Target */
/* ... */
});
Cuando se carga la app, se verifica si se configuró este parámetro de consulta y, si es así, se dibuja la imagen compartida en el lienzo y se borra de la caché. Todo esto sucede en script.mjs
:
const restoreImageFromShare = async () => {
const mediaCache = await getMediaCache();
const image = await mediaCache.match('shared-image');
if (image) {
const blob = await image.blob();
await drawBlob(blob);
await mediaCache.delete('shared-image');
}
};
Esta función se usa cuando se inicializa la app.
if (location.search.includes('share-target')) {
restoreImageFromShare();
} else {
drawDefaultImage();
}
6. 🐟 Agrega compatibilidad con la importación de imágenes
Dibujar todo desde cero es difícil. Agrega una función que permita a los usuarios subir una imagen local desde su dispositivo a la app.
Primero, lee sobre la función drawImage()
del lienzo. A continuación, familiarízate con el elemento <input
.
type=file>
Con esta información, puedes editar el archivo llamado import_image_legacy.mjs
y agregar el siguiente fragmento. En la parte superior del archivo, importa el botón de importación y una función de conveniencia drawBlob()
que te permite dibujar un BLOB en el lienzo.
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png, image/jpeg, image/*';
input.addEventListener('change', () => {
const file = input.files[0];
input.remove();
return resolve(file);
});
input.click();
});
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
7. 🐟 Agrega compatibilidad con la exportación de imágenes
¿Cómo guardará el usuario en su dispositivo un archivo creado en la app? Tradicionalmente, esto se logra con un elemento <a
.
download>
En el archivo export_image_legacy.mjs
, agrega el contenido de la siguiente manera. Importa el botón de exportación y una función de conveniencia toBlob()
que convierte el contenido del lienzo en un BLOB.
import { exportButton, toBlob } from './script.mjs';
export const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
setTimeout(() => a.click(), 0);
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
exportImage(await toBlob());
});
8. 🐟 Se agregó compatibilidad con la API de File System Access
Compartir es bueno, pero es probable que los usuarios quieran guardar su mejor trabajo en sus propios dispositivos. Agrega una función que permita a los usuarios guardar (y volver a abrir) sus dibujos.
Antes, usabas un enfoque heredado de <input type=file>
para importar archivos y un enfoque heredado de <a download>
para exportar archivos. Ahora, usarás la API de File System Access para mejorar la experiencia.
Esta API permite abrir y guardar archivos desde el sistema de archivos del sistema operativo. Edita los dos archivos, import_image.mjs
y export_image.mjs
, respectivamente, agregando el siguiente contenido. Para que se carguen estos archivos, quita los emojis de 🐡 de script.mjs
.
Reemplaza esta línea:
// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
/* ... */
}
…por esta línea:
if ('showOpenFilePicker' in window) {
/* ... */
}
En import_image.mjs
:
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
try {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: 'Image files',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
},
},
],
});
return await handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
En export_image.mjs
:
import { exportButton, toBlob } from './script.mjs';
const exportImage = async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fugu-greetings.png',
types: [
{
description: 'Image file',
accept: {
'image/png': ['.png'],
},
},
],
});
const blob = await toBlob();
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
await exportImage();
});
9. 🐟 Se agregó compatibilidad con la API de Contact Picker
Es posible que los usuarios quieran agregar un mensaje a sus tarjetas de felicitación y dirigirse a alguien personalmente. Agrega una función que permita a los usuarios elegir uno (o varios) de sus contactos locales y agregar sus nombres al mensaje de uso compartido.
En dispositivos iOS o Android, la API de Contact Picker te permite elegir contactos de la app de administrador de contactos del dispositivo y devolverlos a la aplicación. Edita el archivo contacts.mjs
y agrega el siguiente código.
import { contactsButton, ctx, canvas } from './script.mjs';
const getContacts = async () => {
const properties = ['name'];
const options = { multiple: true };
try {
return await navigator.contacts.select(properties, options);
} catch (err) {
console.error(err.name, err.message);
}
};
contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
const contacts = await getContacts();
if (contacts) {
ctx.font = '1em Comic Sans MS';
contacts.forEach((contact, index) => {
ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
});
}
});
10. 🐟 Se agregó compatibilidad con la API de Async Clipboard
Es posible que tus usuarios quieran pegar una imagen de otra app en la tuya o copiar un dibujo de tu app en otra. Agrega una función que les permita copiar y pegar imágenes dentro y fuera de tu app. La API de Async Clipboard admite imágenes PNG, por lo que ahora puedes leer y escribir datos de imágenes en el portapapeles.
Busca el archivo clipboard.mjs
y agrega lo siguiente:
import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';
const copy = async (blob) => {
try {
await navigator.clipboard.write([
/* global ClipboardItem */
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
const paste = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
try {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
return blob;
}
} catch (err) {
console.error(err.name, err.message);
}
}
} catch (err) {
console.error(err.name, err.message);
}
};
copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
await copy(await toBlob());
});
pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
const image = new Image();
image.addEventListener('load', () => {
drawImage(image);
});
image.src = URL.createObjectURL(await paste());
});
11. 🐟 Se agregó compatibilidad con la API de Badging
Cuando los usuarios instalen tu app, aparecerá un ícono en su pantalla principal. Puedes usar este ícono para transmitir información divertida, como la cantidad de pinceladas que se usaron en un dibujo determinado.
Agrega una función que aumente el contador de la insignia cada vez que el usuario realice un nuevo trazo de pincel. La API de Badging permite establecer una insignia numérica en el ícono de la app. Puedes actualizar la insignia cada vez que se produzca un evento pointerdown
(es decir, cuando se produzca un trazo de pincel) y restablecerla cuando se borre el lienzo.
Coloca el siguiente código en el archivo badge.mjs
:
import { canvas, clearButton } from './script.mjs';
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
12. 🐟 Se agregó compatibilidad con la API de Screen Wake Lock
A veces, los usuarios pueden necesitar unos momentos para mirar un dibujo, el tiempo suficiente para que llegue la inspiración. Agrega una función que mantenga la pantalla activa y evite que se active el protector de pantalla. La API de Screen Wake Lock evita que la pantalla del usuario entre en suspensión. El bloqueo de activación se libera automáticamente cuando se produce un evento de cambio de visibilidad según lo define la Visibilidad de la página. Por lo tanto, el bloqueo de activación se debe volver a adquirir cuando la página vuelve a estar visible.
Busca el archivo wake_lock.mjs
y agrega el siguiente contenido. Para probar si funciona, configura el protector de pantalla para que se muestre después de un minuto.
import { wakeLockInput, wakeLockLabel } from './script.mjs';
let wakeLock = null;
const requestWakeLock = async () => {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
} catch (err) {
console.error(err.name, err.message);
}
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
if (wakeLockInput.checked) {
await requestWakeLock();
} else {
wakeLock.release();
}
});
13. 🐟 Agrega compatibilidad con la API de Periodic Background Sync
Comenzar con un lienzo en blanco puede ser aburrido. Puedes usar la API de Periodic Background Sync para inicializar el lienzo de tus usuarios con una imagen nueva cada día, por ejemplo, la foto diaria de fugu de Unsplash.
Esto requiere dos archivos: un archivo periodic_background_sync.mjs
que registra la sincronización periódica en segundo plano y otro archivo image_of_the_day.mjs
que se encarga de descargar la imagen del día.
En periodic_background_sync.mjs
:
import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';
const getPermission = async () => {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
return status.state === 'granted';
};
const registerPeriodicBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready;
try {
registration.periodicSync.register('image-of-the-day-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (err) {
console.error(err.name, err.message);
}
};
navigator.serviceWorker.addEventListener('message', async (event) => {
const fakeURL = event.data.image;
const mediaCache = await getMediaCache();
const response = await mediaCache.match(fakeURL);
drawBlob(await response.blob());
});
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
if (await getPermission()) {
await registerPeriodicBackgroundSync();
}
const mediaCache = await getMediaCache();
let blob = await mediaCache.match('./assets/background.jpg');
if (!blob) {
blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
}
drawBlob(await blob.blob());
});
En image_of_the_day.mjs
:
const getImageOfTheDay = async () => {
try {
const fishes = ['blowfish', 'pufferfish', 'fugu'];
const fish = fishes[Math.floor(fishes.length * Math.random())];
const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
if (!response.ok) {
throw new Error('Response was', response.status, response.statusText);
}
return await response.blob();
} catch (err) {
console.error(err.name, err.message);
}
};
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
try {
const blob = await getImageOfTheDay();
const mediaCache = await getMediaCache();
const fakeURL = './assets/background.jpg';
await mediaCache.put(fakeURL, new Response(blob));
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: fakeURL,
});
});
} catch (err) {
console.error(err.name, err.message);
}
})(),
);
}
});
14. 🐟 Agrega compatibilidad con la API de Shape Detection
A veces, los dibujos de los usuarios o las imágenes de fondo que se usan pueden contener información útil, como códigos de barras. La API de Shape Detection, y específicamente la API de Barcode Detection, te permite extraer esta información. Agrega una función que intente detectar códigos de barras a partir de los dibujos de los usuarios. Busca el archivo barcode.mjs
y agrega el siguiente contenido. Para probar esta función, solo carga o pega una imagen con un código de barras en el lienzo. Puedes copiar un ejemplo de código de barras de una búsqueda de imágenes de códigos QR.
/* global BarcodeDetector */
import {
scanButton,
clearButton,
canvas,
ctx,
CANVAS_BACKGROUND,
CANVAS_COLOR,
floor,
} from './script.mjs';
const barcodeDetector = new BarcodeDetector();
const detectBarcodes = async (canvas) => {
return await barcodeDetector.detect(canvas);
};
scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
const barcodes = await detectBarcodes(canvas);
if (barcodes.length) {
barcodes.forEach((barcode) => {
const rawValue = barcode.rawValue;
if (seenBarcodes.includes(rawValue)) {
return;
}
seenBarcodes.push(rawValue);
ctx.font = '1em Comic Sans MS';
ctx.textAlign = 'center';
ctx.fillStyle = CANVAS_BACKGROUND;
const boundingBox = barcode.boundingBox;
const left = boundingBox.left;
const top = boundingBox.top;
const height = boundingBox.height;
const oneThirdHeight = floor(height / 3);
const width = boundingBox.width;
ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
ctx.fillStyle = CANVAS_COLOR;
ctx.fillText(
rawValue,
left + floor(width / 2),
top + floor(height / 2),
width,
);
});
}
});
15. 🐡 Se agregó compatibilidad con la API de Idle Detection
Si imaginas que tu app se ejecuta en una configuración similar a la de un quiosco, una función útil sería restablecer el lienzo después de un cierto período de inactividad. La API de Idle Detection te permite detectar cuando un usuario ya no interactúa con su dispositivo.
Busca el archivo idle_detection.mjs
y pega el siguiente contenido.
import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';
let controller;
ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';
ephemeralInput.addEventListener('change', async () => {
if (ephemeralInput.checked) {
const state = await IdleDetector.requestPermission();
if (state !== 'granted') {
ephemeralInput.checked = false;
return alert('Idle detection permission must be granted!');
}
try {
controller = new AbortController();
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', (e) => {
const { userState, screenState } = e.target;
console.log(`idle change: ${userState}, ${screenState}`);
if (userState === 'idle') {
clearCanvas();
}
});
idleDetector.start({
threshold: 60000,
signal: controller.signal,
});
} catch (err) {
console.error(err.name, err.message);
}
} else {
console.log('Idle detection stopped.');
controller.abort();
}
});
16. 🐡 Agrega compatibilidad con la API de File Handling
¿Qué sucedería si tus usuarios pudieran hacer doble clic en un archivo de imagen y apareciera tu app? La API de File Handling te permite hacer precisamente eso.
Deberás registrar la PWA como controlador de archivos para imágenes. Esto sucede en el manifiesto de la aplicación web, como se muestra en el siguiente fragmento del archivo manifest.webmanifest
. (Esto ya forma parte del manifiesto, por lo que no es necesario que lo agregues).
{
"file_handlers": [
{
"action": "./",
"accept": {
"image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
}
}
]
}
Para controlar realmente los archivos abiertos, agrega el siguiente código al archivo file-handling.mjs
:
import { drawBlob } from './script.mjs';
const handleLaunchFiles = () => {
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) {
return;
}
launchParams.files.forEach(async (handle) => {
const file = await handle.getFile();
drawBlob(file);
});
});
};
handleLaunchFiles();
17. Felicitaciones
🎉 ¡Lo lograste!
Se están desarrollando tantas APIs de navegador emocionantes en el contexto del Proyecto Fugu 🐡 que este codelab apenas pudo abordar el tema superficialmente.
Para obtener más información, sigue nuestras publicaciones en nuestro sitio web.dev.
Pero eso no es todo. Para las actualizaciones que aún no se publicaron, puedes acceder a nuestro rastreador de la API de Fugu con vínculos a todas las propuestas que se lanzaron, que están en prueba de origen o prueba para desarrolladores, todas las propuestas en las que se comenzó a trabajar y todo lo que se está considerando, pero aún no se comenzó.
Thomas Steiner (@tomayac) escribió este codelab. Con gusto responderé tus preguntas y espero leer tus comentarios. Agradecemos especialmente a Hemanth H.M (@GNUmanth), Christian Liebel (@christianliebel), Sven May (@Svenmay), Lars Knudsen (@larsgk) y Jackie Han (@hanguokai), quienes ayudaron a darle forma a este codelab.