Съдържание на курса
Урок 9 — Екранът и завършената програма
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 2 × 45 мин
Какво изграждаш: уеб таблото и крайния пакет
Какво научаваш: React с TypeScript, hooks, споделени типове между клиент и сървър, основна сигурност (XSS), компилиране и публикуване
След урока ще можеш да
- Пишеш компоненти на React във файлове
.tsxс типизирани свойства и дискриминирано обединение наrevisor. - Използваш
useStateиuseEffect, за да зареждаш данни от API със състояния на зареждане и на грешка, и да отменяш заявката, когато компонентът изчезне. - Споделяш между сървъра и браузъра един и същ договор, с неговия тип и валидацията му при изпълнение, без да влачиш код на Node към екрана.
- Разпознаваш вмъкване на HTML без санитизиране, обясняваш защо отваря уязвимост XSS и я блокираш с типа, с ESLint и с политика за сигурност на съдържанието.
- Пакетираш таблото с esbuild и го сервираш от същия процес като API.
- Тестваш таблото в истински DOM и целия пакет срещу сървъра и казваш честно какво е проверил всеки тест и какво може да провери само браузър.
- Подготвиш артефакта, който се публикува: какво носи, какво не носи и как се инсталира без зависимости за разработка.
Защо, преди как
До предишния урок revisor вече върши трудната работа: валидира конфигурация, проверява истински услуги едновременно, представя отказите като данни, излага HTTP API и се затваря коректно. И все пак JSON отговорът е интерфейс, измислен за друга програма. Човек, който трябва да узнае дали pagos се проваля, може да отвори маршрута, да прочете дълга структура и да търси на око важните полета. Това върши работа за диагностика; не е добър екран за експлоатация.
Таблото променя въпроса от „какви данни има системата?“ на „какво трябва да види някой, за да вземе решение?“. Отчетът трябва да покаже първо името на услугата, дали е налична или в отказ, и данната, която обяснява това заключение: HTTP код и продължителност за наличен отговор, подробност за отказ. Трябва да казва кога още зарежда и какво се е случило, когато зареждането се е провалило, защото екран, който остава празен, не разграничава „няма услуги“ от „не можах да попитам“. И трябва да се обновява сам, защото отчет за наличност, който остарява, е по-лош от липсата на отчет.
React помага да се опише този екран като компоненти. Компонентът е функция, която получава свойства и връща описание на интерфейс. React се грижи да превърне това описание в елементи на браузъра и да ги обновява, когато данните се променят. Това не замества правилата, построени в предишните уроци: таблото трябва да консумира вече решен договор, а не да измисля само какви кодове са успешни, какво означава таймаут или как се валидира конфигурацията.
Този договор вече съществува. Урок 8 раздели вътрешния модел от публичния: сървърът се нуждае от цяла Servicio, с nombre, url и timeoutMs, за да прави заявки, а вътрешното Estado пази тази услуга, защото логиката на проверката се нуждае от нея. Браузърът се нуждае само от ReportePublico, който не носи URL, нито таймаута. Този урок използва това, което разделението оставя готово: src/contrato.ts не импортира нищо от Node, така че същият файл, с типа и валидацията си, пътува към браузъра заедно с таблото. Да споделяш типове не значи да споделяш всичко; значи да споделяш това, което наистина пресича границата, и то само веднъж, за да не могат сървърът и екранът да се разминават, без компилаторът да забележи.
Това намалява един клас разминавания, но не премахва мрежовата граница. Типовете на TypeScript се изтриват преди изпълнението, както видя в урок 0: браузърът получава байтове JSON, а не жива инстанция на ReportePublico. Затова таблото използва същия шаблон от урок 6: това, което пристига през fetch, е unknown, докато esReportePublico не докаже обратното. Споделеният тип казва какво очаква таблото; валидацията проверява, че полученото го изпълнява. Без първото сървърът и таблото се разсинхронизират мълчаливо; без второто прокси, което върне страница с грешка с код 200, кара екрана да се компилира, да стартира и после да се провали.
Екранът въвежда и риск, който не съществува, когато печаташ в конзолата: браузърът тълкува HTML. detalle на един отказ може да съдържа текст, който идва от отдалечена услуга, от конфигурация или от човек. Ако този текст се вмъкне като HTML, може да затвори таг, да създаде нови елементи или да се опита да изпълни код в контекста на този, който е отворил таблото. Това семейство уязвимости се нарича XSS, от cross-site scripting. Не е проблем на „странен текст“: това е проблем на смесването на данни с инструкции за браузъра и е един от най-повтарящите се дефекти в уеб.
В Go разделението прилича на изграждането на специфична структура за HTTP отговор и предаването ѝ на шаблон с автоматично екраниране. Идеята не зависи от езика: вътрешният модел съдържа това, от което програмата се нуждае, за да работи; публичният модел съдържа само необходимото, за да съобщи резултата; а текстът отвън никога не се третира като код. TypeScript добавя предимство, когато сървърът и екранът живеят в едно и също хранилище: договорът може да се назове веднъж и да се провери от двете страни, преди да се изпълни.
Накрая, завършеният продукт не е само кодът, който изглежда добре на твоя компютър. Включва повтаряем начин да се построи, записани зависимости, изход, който може да се инспектира, и безопасна конфигурация за стартиране. Да публикуваш не е да копираш на сляпо цялата директория, нито да качваш тайни заедно с кода: това е да генерираш известен артефакт, да провериш какво съдържа, да инсталираш само необходимото за изпълнение и да внедриш с ясни граници на мрежа, произход и конфигурация. В края на урока revisor е завършен: един-единствен процес на Node, който проверява услугите от servicios.json, отговаря на GET /api/estados и сервира таблото, което консумира този отговор.
Този урок запазва, както 8, структурата на проекта: вход src/main.ts, rootDir ./src, outDir ./dist и скриптовете compilar, verificar, arrancar, probar, lint и formato, с един нов скрипт, empaquetar. Това, което се инсталира ново, се обяснява на място: React, пакетиращ инструмент (esbuild) и DOM за тестове (jsdom). Фигурите от един файл се изпълняват в папката figuras/ от урок 1; нужно е само да инсталираш там същите зависимости, които използва проектът.
cd ~/proyectos/figuras
npm install --save-dev --save-exact react@19.3.0 react-dom@19.3.0 @types/react@19.3.0 @types/react-dom@19.3.0 jsdom@29.1.1 @types/jsdom@28.0.3
Понятията
Компоненти и JSX: функция, която описва част от екрана
JSX прилича на HTML вътре в TypeScript, но не е низ с HTML, който браузърът получава такъв, какъвто е. Това е синтаксис, който TypeScript трансформира в извиквания на React. Затова файлът трябва да завършва на .tsx, а компилацията трябва да включва --jsx react-jsx. Този режим използва автоматичния runtime на React: не е нужно да импортираш идентификатор на име React, само за да се компилира JSX, макар да импортираш конкретните стойности, които използваш, като hooks.
Функционалният компонент получава обект със свойства, обикновено деструктуриран в параметрите му, и връща JSX. Свойствата са договор като параметрите на всяка друга функция. Ако един ред се нуждае от състояние, типът на свойството трябва да го казва. Не го декларирай като unknown, any или обект с незадължителни свойства само за да „накараш екрана да рисува“: това би прехвърлило към екрана несигурност, която моделът вече е разрешил.
Следващата програма използва същия шаблон на дискриминирано обединение от урок 3. Редът обслужва disponible и falla поотделно: в първия клон може да чете codigoHttp; във втория detalle. Не е нужно да питаш дали полетата съществуват, нито да пълниш модела с двусмислени незадължителни свойства. В тази фигура EstadoPublico е опростен спрямо истинския договор на проекта: не носи duracionMs, за да е примерът кратък.
// fig09_01.tsx
import { renderToStaticMarkup } from "react-dom/server";
type EstadoPublico =
| {
readonly nombre: string;
readonly tipo: "disponible";
readonly codigoHttp: number;
}
| {
readonly nombre: string;
readonly tipo: "falla";
readonly detalle: string;
};
function FilaEstado({ estado }: { readonly estado: EstadoPublico }) {
if (estado.tipo === "disponible") {
return (
<li>
<strong>{estado.nombre}</strong> disponible: HTTP {estado.codigoHttp}
</li>
);
}
return (
<li>
<strong>{estado.nombre}</strong> falla: {estado.detalle}
</li>
);
}
const pantalla = renderToStaticMarkup(
<ul>
<FilaEstado estado={{ nombre: "catálogo", tipo: "disponible", codigoHttp: 200 }} />
<FilaEstado estado={{ nombre: "pagos", tipo: "falla", detalle: "tiempo límite" }} />
</ul>,
);
console.log(pantalla);
$ npx tsc --strict --target ES2022 --module nodenext --jsx react-jsx fig09_01.tsx
$ node fig09_01.js
<ul><li><strong>catálogo</strong> disponible: HTTP 200</li><li><strong>pagos</strong> falla: tiempo límite</li></ul>
renderToStaticMarkup превръща компонент в низ с HTML, без да е нужен браузър. Да рендираш е това: да превърнеш описанието, което компонентът връща, в HTML или във видими елементи. Тук служи, за да се види какво произвежда компонентът с контролирани данни. Не добавя интерактивност: генерира статичен HTML, без състояние и без ефекти, и затова не е това, което използва крайното табло. Това, че един компонент може да се изпълни така, като обикновена функция, е точно което го прави лесен за тестване.
Компонентите не е нужно да са класове. Функция с типизирани свойства е обикновена част от TypeScript: може да се извади, тества и чете, без да се учи специална йерархия. React се грижи да тълкува върнатия JSX. Сравнението с Go не е буквално, защото Go няма JSX, но разделението е познато: функция за представяне получава вече валидна структура и произвежда представяне за този, който я консумира.
Вътре в revisor разделението е малко. Panel иска данните и решава кой екран трябва според зареждането; Contenido избира между зарежда, грешка и списък; FilaEstado получава EstadoPublico и го рисува. Никой от тях не решава какъв HTTP маршрут съществува, не чете променливи на средата и не знае какви HTTP кодове значат „наличен“: това вече го е решил сървърът и пристига в полето tipo.
Hooks: състояние и ефекти
Компонент, който само рисува получени данни, е лесният случай. Таблото се нуждае от повече: да поиска данни от API, да чака, да покаже „Cargando…“, да го замени със списъка, когато пристигне, да покаже грешка, ако API се провали, и да го повтаря през определено време. За това React предлага hooks, функции, чието име започва с use и които свързват компонент с възможности на React. Има два, от които се нуждаеш сега.
useState дава памет на компонента. const [total, establecerTotal] = useState<number | undefined>(undefined) декларира стойност, total, която React пази между рисуванията, и функция, establecerTotal, която я променя. Извикването на тази функция не променя променливата в момента: моли React да изпълни отново компонента с новата стойност. Типът между ъгловите скоби описва какви стойности допуска; с дискриминирано обединение типът на състоянието казва точно какви екрани съществуват.
useEffect изпълнява работа, която не е рисуване. Рисуването трябва да е чиста функция на свойствата и състоянието; да искаш данни от мрежа, да планираш таймер или да се абонираш за нещо е ефект и трябва да се прави, след като React е нарисувал, а не по време на това. useEffect(() => { ... }, []) получава функция и списък от зависимости. Функцията се изпълнява след първото рисуване; ако върне друга функция, тази функция за почистване се изпълнява, когато компонентът изчезне или когато някоя зависимост се промени, преди ефектът да се повтори. Списъкът от зависимости е частта, в която най-често се греши: казва от кои стойности зависи ефектът и React го повтаря само когато някоя се промени. Празен списък означава „само при монтирането“.
Следващата фигура е най-малкото, което показва пълния цикъл. Компонент иска, чрез ефект, число, което отнема 10 ms да пристигне; междувременно показва „Cargando…“; когато пристигне, го запазва в състоянието и React го рисува отново. За да се изпълни в Node, без браузър, фигурата създава симулиран документ с jsdom, реализация на DOM, написана на JavaScript, и го инсталира като глобални document и window; React го използва, сякаш е този на браузъра. act е инструментът на React за тестове: изпълнява кода, който предизвиква промени, изчаква React да завърши прилагането им и едва тогава връща управлението, така че това, което четеш след това, е това, което би видял човек.
// fig09_02.tsx
import { JSDOM } from "jsdom";
import { act, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
const dom = new JSDOM('<!doctype html><div id="raiz"></div>');
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
IS_REACT_ACT_ENVIRONMENT: true,
});
function contarServicios(): Promise<number> {
return new Promise((resolve) => setTimeout(() => resolve(2), 10));
}
function Resumen() {
const [total, establecerTotal] = useState<number | undefined>(undefined);
useEffect(() => {
void contarServicios().then(establecerTotal);
}, []);
return <p>{total === undefined ? "Cargando…" : `${total} servicios`}</p>;
}
const raiz = dom.window.document.getElementById("raiz");
if (raiz === null) {
throw new Error("falta el elemento #raiz");
}
const arbol = createRoot(raiz);
await act(async () => {
arbol.render(<Resumen />);
});
console.log(`primer render: ${raiz.innerHTML}`);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
console.log(`tras el efecto: ${raiz.innerHTML}`);
await act(async () => {
arbol.unmount();
});
dom.window.close();
$ npx tsc --strict --target ES2022 --module nodenext --jsx react-jsx fig09_02.tsx
$ node fig09_02.js
primer render: <p>Cargando…</p>
tras el efecto: <p>2 servicios</p>
В този изход се виждат три неща. Първото рисуване показва „Cargando…“, защото началното състояние е undefined. Ефектът е започнал след това рисуване, не преди. А когато обещанието е разрешено, establecerTotal(2) е предизвикало второ рисуване с новата стойност. Забележи и какво не прави фигурата: не използва setTimeout вътре в компонента и не вика contarServicios() в тялото на функцията. Ако я извикаш в тялото, би се изпълнявала при всяко рисуване, а тъй като всеки отговор променя състоянието и предизвиква ново рисуване, ще получиш цикъл от заявки.
Има клопка, която е добре да се назове сега. Ефект, който иска данни, може да завърши, след като компонентът вече не съществува: човекът е сменил екрана или, в тестовете, си демонтирал дървото. Ако отговорът пристигне тогава и извика establecerTotal, опитваш да обновиш компонент, който го няма. Защитата е почистването на ефекта: таблото създава AbortController във всеки ефект, предава сигнала му на fetch и го прекъсва във функцията за почистване, точно механизмът за отмяна, който познаваш от урок 5, който сега изпълнява нова задача. А когато заявката е прекъсната, catch го разпознава с control.signal.aborted и не записва никакво състояние на грешка: да бъдеш отменен не е отказ.
Споделени типове: един договор, две страни
Урок 8 създаде src/contrato.ts с три части: типа EstadoPublico, типа ReportePublico и защитата esReportePublico. Сървърът ги използва, за да построи отговора си (aReportePublico връща ReportePublico), а таблото ги използва, за да го получи. Това е конкретната форма на „споделени типове между клиент и сървър“: не публикуван пакет, нито инструмент за генериране на код, а файл от същия проект, който и двете страни импортират.
Две правила го карат да работи. Първото: споделеният файл съдържа само това, което има смисъл и в двете среди. contrato.ts импортира единствено esRegistro от configuracion.ts, чиста функция без зависимости от Node. Ако импортираше node:fs или node:http, пакетиращият инструмент би се опитал да го занесе към браузъра, който няма тези модули, и пакетирането би се провалило или, още по-лошо, би произвело счупен пакет. Думата „споделен“ не разрешава да споделяш код, който служи само в Node, нито браузърът да влачи функции, които четат файлове или тайни. Споделяй типове и чисти трансформации; оставяй границите на мрежата, диска и средата в техните слоеве.
Второто правило: споделеното е публичният договор, а не вътрешният модел. Ако таблото импортираше Estado, със своята Servicio, сървърът би трябвало да сериализира URL на всяка услуга, за да изпълни отговорът този тип, или таблото би останало убедено, че получава данни, които мрежата всъщност не носи; и в двата случая вътрешният тип би диктувал какво се публикува. Ако някой смени duracionMs на duracion в contrato.ts, TypeScript ще посочи както преобразувателя на сървъра, aEstadoPublico, така и реда на таблото, който чете старото поле. Това е ползата: разминаването се открива при компилация, а не когато видиш празен екран в продукция.
И въпреки това типът не валидира нищо при изпълнение. Когато браузърът получи тялото на GET /api/estados, await respuesta.json() предава стойност от външна граница и изкушението е да напишеш това:
const reporte = (await respuesta.json()) as ReportePublico;
Твърдението не инспектира отговора. Ако стара версия на API върне codigo вместо codigoHttp или ако прокси върне HTML страница с код 200, екранът се компилира и после се проваля. Таблото запазва практиката от урок 6: получава unknown, вика esReportePublico и едва тогава произвежда ReportePublico. Преди да парсне, освен това, проверява HTTP кода: отговор 503 може да носи валиден JSON и да не е отчетът, който таблото е очаквало. Да парснеш е да трансформираш сериализирано представяне, като JSON текст, в стойности на JavaScript, които още трябва да валидираш. Не превръщай неуспешен отговор в празен списък: това би направило отказ на API да изглежда като „всичко е наред, но няма услуги“.
Тази валидация се случва веднъж, в cargar.ts, до HTTP извикването. Panel не получава unknown и не пита дали reporte.estados е масив. Компонент, който прави валидация на мрежа, подреждане, форматиране и JSX едновременно, накрая е труден за тестване и четене. Слоят, който получава данни, отговаря „отговорът изпълнява ли договора?“; таблото отговаря „как се показва вече надежден договор?“. И това разделение отваря врата, която ще използваш в тестовете: Panel не знае откъде идват данните; получава функция cargar, така че тест му предава контролирана функция, а истинската програма му предава cargarReporte.
XSS: външният текст не бива да се превръща в инструкции
XSS се случва, когато данни, които друга страна контролира, завършват тълкувани като HTML или JavaScript вътре в страница. Един отказ изглежда невинен източник: отдалечена услуга връща текст на грешка, revisor го пази като detalle и таблото го показва. Но този текст го пише този, който контролира отдалечената услуга, и би могъл да бъде <img src=x onerror=alert(1)>. Ако таблото вмъкне този низ като HTML, браузърът създава елемент img, изображението не се зарежда и атрибутът onerror изпълнява код в страницата, с правата на този, който я е отворил.
Основната защита е да запазиш правилния семантичен тип. Подробността е текст; следователно трябва да е дете на JSX, като {detalle}. React го третира като текст и екранира знаците, които значат нещо за HTML: < става <, > става >, & става &. Следващата фигура го показва: макар входът да съдържа таг, изходът съдържа < и >, които браузърът показва като видими знаци, вместо да ги тълкува като изображение.
// fig09_03.tsx
import { renderToStaticMarkup } from "react-dom/server";
function Detalle({ texto }: { readonly texto: string }) {
return <p>{texto}</p>;
}
const detalleExterno = "<img src=x onerror=alert(1)>";
console.log(renderToStaticMarkup(<Detalle texto={detalleExterno} />));
$ npx tsc --strict --target ES2022 --module nodenext --jsx react-jsx fig09_03.tsx
$ node fig09_03.js
<p><img src=x onerror=alert(1)></p>
Сравни с алтернативата. React има свойство, което се казва, нарочно, dangerouslySetInnerHTML: „задай HTML по опасен начин“. Името му съществува, за да те спре, преди да го използваш. React не може да знае дали HTML, който му даваш, е генериран от надежден източник, почистен от актуален санитизатор или е дошъл от мрежа без валидация; така че спира да екранира и го вмъква такъв, какъвто е. Същата фигура, с това свойство, произвежда друго:
// fig09_04.tsx
import { renderToStaticMarkup } from "react-dom/server";
function DetalleInseguro({ texto }: { readonly texto: string }) {
return <p dangerouslySetInnerHTML={{ __html: texto }} />;
}
const detalleExterno = "<img src=x onerror=alert(1)>";
console.log(renderToStaticMarkup(<DetalleInseguro texto={detalleExterno} />));
$ npx tsc --strict --target ES2022 --module nodenext --jsx react-jsx fig09_04.tsx
$ node fig09_04.js
<p><img src=x onerror=alert(1)></p>
Този втори изход е дефектът. Тагът img вече не е записан като текст: това е елемент, който браузърът ще създаде. Същото се случва и без React: да присвоиш на elemento.innerHTML текст, който идва отвън, има точно същия проблем и затова не бива да се появява и в таблото. Когато ти трябва да покажеш текст без React, elemento.textContent = texto прави правилното, защото браузърът не тълкува това, което присвояваш по този път.
Честа грешка е да напишеш домашна функция, която заменя само <script> или премахва конкретна дума. HTML има атрибути за събития, URL със схема javascript:, обекти (entities), SVG, стилове и варианти на кодиране; непълен списък от замени създава фалшиво усещане за сигурност. Ако някой продукт наистина трябва да показва чужд HTML, например редакционно съдържание с удебелен шрифт, отговорът е поддържан и тестван санитизатор, който обхожда HTML и оставя само разрешено подмножество от тагове и атрибути, приложен преди точката на рисуване и с тестове с враждебни входове. За оперативните подробности на revisor това изискване не съществува и правилният дизайн е изобщо да не се тълкува HTML.
Тъй като правило, което никой не следи, се забравя, проектът го превръща в автоматична проверка. eslint.config.js от този урок добавя правилото no-restricted-syntax с два селектора: единият забранява атрибута dangerouslySetInnerHTML, а другият присвояването към innerHTML. Ако някой напише някое от двете, npm run lint се проваля със съобщението, което ти си написал. Това е, което отпечатва с пробен файл, src/panel/Mala.tsx, който съдържа и двете неща (изтрий го след това):
export function Mala({ texto }: { readonly texto: string }) {
return <p dangerouslySetInnerHTML={{ __html: texto }} />;
}
export function pintar(elemento: HTMLElement, texto: string): void {
elemento.innerHTML = texto;
}
$ npm run lint
> lint
> eslint src
/home/tu-usuario/proyectos/revisor/src/panel/Mala.tsx
2:13 error No insertes HTML sin sanitizar: usa texto como hijo de JSX no-restricted-syntax
6:3 error No asignes innerHTML: usa textContent o un componente de React no-restricted-syntax
✖ 2 problems (2 errors, 0 warnings)
Правило на lint не е пълна защита: не вижда присвояване, направено по друг път, и някой може да го изключи. Затова проектът добавя втори слой, който не зависи от това някой да помни нещо: политика за сигурност на съдържанието, или CSP (Content Security Policy). Това е заглавна част на отговора, която казва на браузъра какви ресурси има право да зарежда или изпълнява в тази страница. Сървърът я изпраща със страницата: default-src 'none' забранява всичко по подразбиране, а после се разрешава единствено това, от което таблото се нуждае: script-src 'self' (само скриптове, сервирани от същия произход като страницата, което блокира вмъкнат <script> или onerror), style-src 'self' и connect-src 'self' (таблото може да прави fetch само към собствения си произход). Освен това frame-ancestors 'none' не позволява друга страница да вмъкне твоята в рамка, base-uri 'none' блокира смяната на базата на относителните пътища, а form-action 'none' не позволява вмъкната форма да изпраща данни на друг сайт. CSP не променя факта, че небезопасното вмъкване е дефект; тя е мрежата под въжеиграча, а не разрешение да спреш да гледаш.
Остават две напомняния. Първо, ако таблото някой ден показва URL, не изграждай атрибути чрез конкатениране на низове: предай стойността като свойство на JSX и валидирай протокола, който продуктът ти допуска, защото href с javascript: изпълнява код, макар да не съдържа никакъв таг. Типовете описват текст; политиката за сигурност решава кой текст е разрешена цел. Второ, публичният договор вече върши своята част: вътрешният URL на всяка услуга не стига до таблото, което намалява както излагането на инфраструктура, така и количеството външен текст, който би могъл да докосне страницата. Сигурността не е ред код накрая; започва с решението кои стойности пресичат всяка граница.
Таблото отвътре
Таблото са четири малки файла в src/panel/, плюс един стилов лист. Удобно е да ги четеш в реда, в който браузърът ги използва.
cargar.ts е мрежовата граница на таблото. Дефинира типа Cargar, функция, която получава AbortSignal и връща обещание с ReportePublico, и истинската реализация, cargarReporte. Тя иска /api/estados със сигнала, проверява respuesta.ok и хвърля „la API respondió 503“, ако не е, превръща тялото с respuesta.json() в unknown (анотация, без твърдение) и го пуска през esReportePublico; ако се провали, хвърля „la API no entregó un reporte válido“. Вторият параметър, base, е "" в браузъра, където /api/estados се разрешава спрямо страницата, която го е заредила, а тестовете го използват, за да сочат към локален сървър с пълния му адрес. Трите развръзки (отчет, невалиден код, нарушен договор) имат тест, cargar.test.ts, срещу истински HTTP сървър, който отговаря това, от което се нуждае всеки случай.
useReporte.ts е собствен hook, тоест функция, чието име започва с use и която комбинира други hooks. Декларира типа Carga като дискриминирано обединение от три алтернативи: cargando, listo с отчета и error с подробността. Това е шаблонът от урок 3, приложен към състоянието на един екран, и има същото предимство: невъзможно е да се представи „listo“ без отчет или „error“ без подробност. Hook-ът пази това състояние с useState и число intento, което служи само за да поиска от ефекта да се повтори. Ефектът създава AbortController, дефинира pedir, която вика cargar(control.signal) и записва listo или error, изпълнява я веднъж, планира setInterval, за да я повтаря на всеки cadaMs милисекунди, и връща почистването, което прекъсва заявката и спира таймера. Връща състоянието и recargar, който поставя зареждането на cargando и увеличава intento; тъй като intento е в списъка със зависимости, ефектът се почиства и повтаря.
Списъкът със зависимости, [cargar, cadaMs, intento], заслужава пауза, защото там се крият фините грешки. cargar е там, защото ефектът я използва: ако се промени, ефектът трябва да се повтори с новата. Това изисква извикващият да предава стабилна функция: ако Panel получаваше нова функция при всяко рисуване, ефектът би се повтарял при всяко рисуване и би получил цикъла от заявки, който вече познаваш. cliente.tsx монтира приложението веднъж с render(...) и не се изпълнява отново: стрелковата функция, която предава като свойство, се създава този единствен път и е една и съща през целия живот на таблото. Ако я създаваше компонент, който се рисува много пъти, трябваше да я фиксираш с useCallback или да я декларираш извън него. ESLint, в този проект, не проверява списъци със зависимости; официалният пакет, който го прави, е eslint-plugin-react-hooks и е добра следваща инсталация за по-голям проект на React.
Panel.tsx е само представяне. Contenido получава Carga и избира какво да нарисува със switch, чийто default използва защитата never от урок 3: ако утре добавиш алтернативата vacio към Carga и забравиш да я нарисуваш, компилаторът го казва. Всеки екран носи атрибут role (status за „Cargando…“ и alert за грешката), който служи на екранните четци да ги обявяват и на тестовете да ги намират, без да зависят от точния текст. FilaEstado е тази от фигура 1, сега с duracionMs и CSS клас за всеки тип. Panel събира hook-а, съдържанието и бутон „Actualizar“, който вика recargar. Всеки ред носи като key позицията заедно с името: servicios.json не налага имената да са уникални, а два реда със същия ключ биха объркали React; тъй като списъкът се заменя цял при всяко зареждане и редовете не пазят състояние, позицията не причинява никакъв проблем.
cliente.tsx е единственият файл, който докосва документа. Търси елемента #raiz, проваля се изрично, ако не съществува, и монтира таблото с createRoot(raiz).render(...). Това е границата между React и страницата и затова е единственото нещо, което тестовете на компоненти не импортират: тестовете монтират Panel сами, в симулиран документ.
Листът panel.css е обикновен CSS и не докосва TypeScript, с едно нарочно решение: в JSX няма атрибути style. Атрибут style вътре в реда би нарушил политиката style-src 'self', която сървърът изпраща, защото браузърът третира стила вътре в реда като код, който не идва от произхода. Целият външен вид живее в листа, който браузърът зарежда от същия произход.
От кода до браузъра: пакетиране с esbuild
Досега всичко, което компилираше, работи в Node. Таблото работи в браузър, а браузърът не умее да изпълнява .tsx, нито да разрешава import { createRoot } from "react-dom/client", което е име на пакет, а не път до файл. Трябва да се произведе един-единствен JavaScript файл, който браузърът може да зареди с таг <script>. Тази задача се нарича пакетиране (bundling) и я върши пакетиращ инструмент (bundler): тръгва от входен файл, следва всички import, събира това, което намери, и записва резултата.
Проектът използва esbuild, пакетиращ инструмент с отворен код, много бърз, чиято официална документация е на esbuild.github.io. Скриптът empaquetar е един-единствен ред:
esbuild src/panel/cliente.tsx src/panel/panel.css --bundle --minify --format=iife --log-level=warning --outdir=dist/publico
Всяка опция има причина. Първите два аргумента са входните файлове: програмата на таблото и стиловият лист. --bundle е това, което кара esbuild да следва import, включително тези на react и react-dom, и да ги включва в изхода; без тази опция само би превел файла и би оставил import на пакет, който браузърът не би могъл да разреши. --minify маха интервали и скъсява имена, за да тежи файлът по-малко, и има ефект, който има значение: когато се използва, esbuild дефинира process.env.NODE_ENV като "production" и React включва тогава производствената си версия, без проверките и предупрежденията за разработка. --format=iife записва резултата като функция, която се изпълнява веднага; работи с обикновен <script>, без да зависи от модули на браузъра. --log-level=warning заглушава информационните съобщения и оставя само предупреждения и грешки. --outdir=dist/publico поставя резултата в dist/publico/, което е това, което сървърът чете: cliente.js и panel.css.
Има последица, която обърква, ако не се каже. esbuild превръща TypeScript в JavaScript, като изтрива типовете, но не ги проверява. Който проверява типовете, продължава да е tsc: затова npm run verificar съществува и затова npm run empaquetar може да пакетира файл с грешки в типовете, без да се оплаче. Двете команди вършат различна работа: едната отговаря дали програмата е правилна, другата произвежда това, което се предава. Pipeline, който само пакетира, не би проверил нищо.
Друга последица: същият tsconfig.json сега компилира едновременно сървъра и таблото. Затова включва "jsx": "react-jsx" и "lib": ["ES2022", "DOM"], което декларира типовете на браузъра (document, window, HTMLElement). Това е опростяване с цена: кодът на сървъра също „вижда“ document и недоглеждане, което го използва, би се компилирало и би се провалило при изпълнение. В по-голям проект те се разделят на две конфигурации, една за сървъра и друга за таблото, които споделят файла contrato.ts; тук една запазва урока фокусиран.
Обърни внимание накрая къде остават React и React DOM. Тъй като пакетиращият инструмент ги копира вътре в cliente.js, сървърът, който работи в продукция, не ги импортира: инсталират се с --save-dev, защото са нужни само за построяване и тестване. Това е противоинтуитивна разлика спрямо приложение, което рисува на сървъра, и има практическа последица, която ще видиш в „Компилиране и публикуване“: производственият артефакт не се нуждае от никаква зависимост.
Сървърът сервира таблото
В урок 8 crearServidor получаваше две неща: функция, която взема отчета, и журнал (log). Сега получава трето, leerActivo, функцията, която предава съдържанието на двата файла, които произвежда пакетиращият инструмент. Активът е статичен файл, който сървърът предава такъв, какъвто е, като скрипт или стилов лист. Типът Activo е обединението "cliente.js" | "panel.css": няма начин да поискаш файл, който не е в този списък. Това е защитата срещу класическа уязвимост, обхождането на пътища (path traversal): сървър, който съставя пътя на файл от това, което пристига в URL, като /../../etc/passwd, накрая предава файлове, които никога не е искал да публикува. Тук URL се сравнява само с два известни маршрута, а името на файла го решава програмата, не клиентът; всеки друг маршрут е 404 от същата Ruta от урок 8. main.ts предава истинската реализация, readFile върху dist/publico/<activo>, разрешена с import.meta.url, за да работи независимо от коя папка стартираш процеса.
Обединението Ruta расте с две алтернативи, pagina за GET / и activo за двата файла, а switch с never кара компилатора да те принуди да обслужиш всяка. Страницата, pagina.ts, е минимален HTML документ, пазен като текстова константа: <div id="raiz">, връзката към /panel.css и <script src="/cliente.js" defer>. Атрибутът defer кара браузъра да изпълни скрипта, когато е свършил да чете документа, така че #raiz вече съществува.
Отговорите получават заглавни части за сигурност. Всички носят x-content-type-options: nosniff, което забранява на браузъра да налучква тип съдържание, различен от декларирания (без него браузър би могъл да третира текст като скрипт). Страницата носи освен това политиката CSP от предишния раздел. JSON отговорите носят cache-control: no-store, защото състоянието на услугите се променя и никой не бива да вижда отчет, запазен в паметта на посредник. А ако четенето на актив се провали, например защото си забравил да изпълниш npm run empaquetar, грешката се записва в журнала с причината ѝ и клиентът получава общ 500, както в урок 8.
Едно решение, което е добре да се каже: таблото и API споделят произход, тоест същата комбинация от схема, сървър и порт. Затова fetch("/api/estados") на таблото не се нуждае от CORS, политиката на браузъра, която решава дали страница от един произход може да чете отговори от друг и която се конфигурира със заглавни части като Access-Control-Allow-Origin. Докато таблото и API излизат от същия процес, няма какво да се конфигурира; ако някой ден ги разделиш, това ще е първият проблем, който ще срещнеш, а правилният отговор е да декларираш изрично разрешените произходи, а не да отговаряш * по удобство.
Завършеният revisor: сглобяване и тестове
Сега всички части са налице. Пълният проект е следният. Всеки файл се появява веднъж; тези, които не са се променили от урок 8, носят същото обяснение от там и са в края.
Първо, конфигурацията на проекта. package.json получава скрипта empaquetar и новите зависимости за разработка, с точна версия. Инсталирай ги от корена на revisor/ с тази команда; npm ги добавя към devDependencies. Скриптът empaquetar го добавяш на ръка, а версиите на зависимостите от урок 7 може да се появят с ^ във файла ти: няма значение, package-lock.json фиксира инсталираното, но можеш да оставиш package.json като този отдолу, ако искаш.
npm install --save-dev --save-exact react@19.3.0 react-dom@19.3.0 @types/react@19.3.0 @types/react-dom@19.3.0 esbuild@0.28.2 jsdom@29.1.1 @types/jsdom@28.0.3
{
"name": "revisor",
"private": true,
"type": "module",
"scripts": {
"compilar": "tsc",
"verificar": "tsc --noEmit",
"empaquetar": "esbuild src/panel/cliente.tsx src/panel/panel.css --bundle --minify --format=iife --log-level=warning --outdir=dist/publico",
"arrancar": "node dist/main.js",
"probar": "npm run compilar && node --test \"dist/**/*.test.js\"",
"lint": "eslint src",
"formato": "prettier --check src"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/jsdom": "28.0.3",
"@types/node": "24",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@typescript/native": "npm:typescript@^7.0.2",
"esbuild": "0.28.2",
"eslint": "10.11.0",
"jsdom": "29.1.1",
"prettier": "3.9.9",
"react": "19.3.0",
"react-dom": "19.3.0",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "8.71.0"
}
}
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"],
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"sourceMap": true
},
"include": ["src"]
}
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended, {
rules: {
"no-restricted-syntax": [
"error",
{
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
message: "No insertes HTML sin sanitizar: usa texto como hijo de JSX.",
},
{
selector: "AssignmentExpression[left.property.name='innerHTML']",
message: "No asignes innerHTML: usa textContent o un componente de React.",
},
],
},
});
Споделеният договор и таблото. contrato.ts е този от урок 8, без промени; показва се тук, защото сега го импортират и двете страни.
// fig09_05/src/contrato.ts
import { esRegistro } from "./configuracion.js";
export type EstadoPublico =
| {
readonly nombre: string;
readonly tipo: "disponible";
readonly codigoHttp: number;
readonly duracionMs: number;
}
| {
readonly nombre: string;
readonly tipo: "falla";
readonly detalle: string;
};
export interface ReportePublico {
readonly estados: readonly EstadoPublico[];
}
function esEstadoPublico(valor: unknown): valor is EstadoPublico {
if (!esRegistro(valor) || typeof valor.nombre !== "string") {
return false;
}
if (valor.tipo === "disponible") {
return typeof valor.codigoHttp === "number" && typeof valor.duracionMs === "number";
}
return valor.tipo === "falla" && typeof valor.detalle === "string";
}
export function esReportePublico(valor: unknown): valor is ReportePublico {
return esRegistro(valor) && Array.isArray(valor.estados) && valor.estados.every(esEstadoPublico);
}
// fig09_05/src/panel/cargar.ts
import { esReportePublico, type ReportePublico } from "../contrato.js";
export type Cargar = (senal: AbortSignal) => Promise<ReportePublico>;
export async function cargarReporte(senal: AbortSignal, base = ""): Promise<ReportePublico> {
const respuesta = await fetch(`${base}/api/estados`, { signal: senal });
if (!respuesta.ok) {
throw new Error(`la API respondió ${respuesta.status}`);
}
const cuerpo: unknown = await respuesta.json();
if (!esReportePublico(cuerpo)) {
throw new Error("la API no entregó un reporte válido");
}
return cuerpo;
}
// fig09_05/src/panel/useReporte.ts
import { useEffect, useState } from "react";
import type { ReportePublico } from "../contrato.js";
import type { Cargar } from "./cargar.js";
export type Carga =
| { readonly tipo: "cargando" }
| { readonly tipo: "listo"; readonly reporte: ReportePublico }
| { readonly tipo: "error"; readonly detalle: string };
export function useReporte(
cargar: Cargar,
cadaMs: number,
): { readonly carga: Carga; readonly recargar: () => void } {
const [carga, establecerCarga] = useState<Carga>({ tipo: "cargando" });
const [intento, establecerIntento] = useState(0);
useEffect(() => {
const control = new AbortController();
async function pedir(): Promise<void> {
try {
const reporte = await cargar(control.signal);
establecerCarga({ tipo: "listo", reporte });
} catch (error: unknown) {
if (control.signal.aborted) {
return;
}
const detalle = error instanceof Error ? error.message : "falló la carga";
establecerCarga({ tipo: "error", detalle });
}
}
void pedir();
const temporizador = setInterval(() => void pedir(), cadaMs);
return () => {
control.abort();
clearInterval(temporizador);
};
}, [cargar, cadaMs, intento]);
function recargar(): void {
establecerCarga({ tipo: "cargando" });
establecerIntento((actual) => actual + 1);
}
return { carga, recargar };
}
// fig09_05/src/panel/Panel.tsx
import type { EstadoPublico } from "../contrato.js";
import type { Cargar } from "./cargar.js";
import { useReporte, type Carga } from "./useReporte.js";
function FilaEstado({ estado }: { readonly estado: EstadoPublico }) {
if (estado.tipo === "disponible") {
return (
<li className="disponible">
<strong>{estado.nombre}</strong>: disponible (HTTP {estado.codigoHttp}, {estado.duracionMs}{" "}
ms)
</li>
);
}
return (
<li className="falla">
<strong>{estado.nombre}</strong>: falla ({estado.detalle})
</li>
);
}
function Contenido({ carga }: { readonly carga: Carga }) {
switch (carga.tipo) {
case "cargando":
return <p role="status">Cargando…</p>;
case "error":
return <p role="alert">No se pudo cargar el reporte: {carga.detalle}</p>;
case "listo":
return (
<ul>
{carga.reporte.estados.map((estado, posicion) => (
<FilaEstado key={`${posicion}-${estado.nombre}`} estado={estado} />
))}
</ul>
);
default: {
const sinAtender: never = carga;
throw new Error(`carga sin atender: ${JSON.stringify(sinAtender)}`);
}
}
}
export function Panel({
cargar,
cadaMs = 10_000,
}: {
readonly cargar: Cargar;
readonly cadaMs?: number;
}) {
const { carga, recargar } = useReporte(cargar, cadaMs);
return (
<main>
<h1>Revisor</h1>
<Contenido carga={carga} />
<button type="button" onClick={recargar}>
Actualizar
</button>
</main>
);
}
// fig09_05/src/panel/cliente.tsx
import { createRoot } from "react-dom/client";
import { cargarReporte } from "./cargar.js";
import { Panel } from "./Panel.js";
const raiz = document.getElementById("raiz");
if (raiz === null) {
throw new Error("falta el elemento #raiz en la página");
}
createRoot(raiz).render(<Panel cargar={(senal) => cargarReporte(senal)} />);
body {
font-family: system-ui, sans-serif;
margin: 2rem auto;
max-width: 40rem;
padding: 0 1rem;
}
ul {
list-style: none;
padding: 0;
}
li {
border-left: 0.5rem solid #888;
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
}
li.disponible {
border-color: #1a7f37;
}
li.falla {
border-color: #cf222e;
}
Сървърът, неговата страница и входната точка.
// fig09_05/src/pagina.ts
export const paginaInicial = `<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Revisor</title>
<link rel="stylesheet" href="/panel.css" />
</head>
<body>
<div id="raiz"></div>
<script src="/cliente.js" defer></script>
</body>
</html>
`;
// fig09_05/src/servidor.ts
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { Bitacora } from "./bitacora.js";
import type { ReportePublico } from "./contrato.js";
import { paginaInicial } from "./pagina.js";
export type ObtenerReporte = () => Promise<ReportePublico>;
export type Activo = "cliente.js" | "panel.css";
export type LeerActivo = (activo: Activo) => Promise<string>;
export interface OpcionesServidor {
readonly obtenerReporte: ObtenerReporte;
readonly leerActivo: LeerActivo;
readonly registrar: Bitacora;
}
type Ruta =
| { readonly tipo: "pagina" }
| { readonly tipo: "activo"; readonly activo: Activo }
| { readonly tipo: "salud" }
| { readonly tipo: "estados" }
| { readonly tipo: "no-encontrada" };
const POLITICA_PAGINA = [
"default-src 'none'",
"script-src 'self'",
"style-src 'self'",
"connect-src 'self'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
].join("; ");
function rutaDe(url: string | undefined): string {
try {
return new URL(url ?? "/", "http://revisor.local").pathname;
} catch {
return "?";
}
}
function reconocerRuta(url: string | undefined): Ruta {
switch (rutaDe(url)) {
case "/":
return { tipo: "pagina" };
case "/cliente.js":
return { tipo: "activo", activo: "cliente.js" };
case "/panel.css":
return { tipo: "activo", activo: "panel.css" };
case "/salud":
return { tipo: "salud" };
case "/api/estados":
return { tipo: "estados" };
default:
return { tipo: "no-encontrada" };
}
}
function enviar(
respuesta: ServerResponse,
codigo: number,
tipo: string,
cuerpo: string,
extra: Record<string, string> = {},
): void {
respuesta.writeHead(codigo, {
"content-type": tipo,
"x-content-type-options": "nosniff",
...extra,
});
respuesta.end(cuerpo);
}
function enviarJson(respuesta: ServerResponse, codigo: number, cuerpo: unknown): void {
enviar(respuesta, codigo, "application/json; charset=utf-8", JSON.stringify(cuerpo), {
"cache-control": "no-store",
});
}
function errorInterno(opciones: OpcionesServidor, respuesta: ServerResponse, error: unknown): void {
opciones.registrar({
evento: "error",
detalle: error instanceof Error ? error.message : "falla desconocida",
});
enviarJson(respuesta, 500, { detalle: "error interno" });
}
async function atender(
solicitud: IncomingMessage,
respuesta: ServerResponse,
opciones: OpcionesServidor,
): Promise<void> {
const inicio = performance.now();
respuesta.once("finish", () => {
const duracion = Math.round(performance.now() - inicio);
opciones.registrar({
evento: "solicitud",
detalle: `${solicitud.method ?? "?"} ${rutaDe(solicitud.url)} ${respuesta.statusCode} ${duracion} ms`,
});
});
if (solicitud.method !== "GET") {
respuesta.setHeader("allow", "GET");
enviarJson(respuesta, 405, { detalle: "método no permitido" });
return;
}
const ruta = reconocerRuta(solicitud.url);
switch (ruta.tipo) {
case "pagina":
enviar(respuesta, 200, "text/html; charset=utf-8", paginaInicial, {
"content-security-policy": POLITICA_PAGINA,
});
return;
case "activo":
try {
const tipo = ruta.activo === "cliente.js" ? "text/javascript" : "text/css";
enviar(respuesta, 200, `${tipo}; charset=utf-8`, await opciones.leerActivo(ruta.activo));
} catch (error: unknown) {
errorInterno(opciones, respuesta, error);
}
return;
case "salud":
enviar(respuesta, 200, "text/plain; charset=utf-8", "ok");
return;
case "estados":
try {
enviarJson(respuesta, 200, await opciones.obtenerReporte());
} catch (error: unknown) {
errorInterno(opciones, respuesta, error);
}
return;
case "no-encontrada":
enviarJson(respuesta, 404, { detalle: "ruta no encontrada" });
return;
default: {
const sinAtender: never = ruta;
throw new Error(`ruta sin atender: ${JSON.stringify(sinAtender)}`);
}
}
}
export function crearServidor(opciones: OpcionesServidor): Server {
return createServer((solicitud, respuesta) => {
atender(solicitud, respuesta, opciones).catch((error: unknown) => {
opciones.registrar({
evento: "error",
detalle: error instanceof Error ? error.message : "falla desconocida",
});
if (respuesta.headersSent) {
respuesta.end();
} else {
enviarJson(respuesta, 500, { detalle: "error interno" });
}
});
});
}
export function escuchar(servidor: Server, puerto: number): Promise<void> {
return new Promise((resolve, reject) => {
servidor.once("error", reject);
servidor.listen(puerto, "127.0.0.1", () => {
servidor.off("error", reject);
resolve();
});
});
}
export function cerrar(servidor: Server): Promise<void> {
return new Promise((resolve, reject) => {
servidor.close((error) => {
if (error === undefined) {
resolve();
} else {
reject(error);
}
});
});
}
export function puertoDe(servidor: Server): number {
const direccion = servidor.address();
if (direccion === null || typeof direccion === "string") {
throw new Error("el servidor no escucha en un puerto TCP");
}
return direccion.port;
}
// fig09_05/src/main.ts
import { readFile } from "node:fs/promises";
import { leerServiciosDeArchivo } from "./archivo.js";
import { bitacoraEnConsola as registrar } from "./bitacora.js";
import { leerPuerto } from "./configuracion.js";
import { consultarConFetch } from "./consulta.js";
import { aReportePublico } from "./reporte.js";
import { revisarTodos } from "./revisar.js";
import { cerrar, crearServidor, escuchar, puertoDe } from "./servidor.js";
function fallarArranque(evento: string, detalle: string): void {
registrar({ evento, detalle });
process.exitCode = 1;
}
async function main(): Promise<void> {
const puerto = leerPuerto(process.env.PUERTO);
if (!puerto.ok) {
fallarArranque("configuracion-invalida", puerto.detalle);
return;
}
const servicios = await leerServiciosDeArchivo("servicios.json");
if (!servicios.ok) {
fallarArranque("configuracion-invalida", servicios.detalle);
return;
}
const servidor = crearServidor({
obtenerReporte: async () =>
aReportePublico(await revisarTodos(servicios.valor, consultarConFetch)),
leerActivo: (activo) => readFile(new URL(`./publico/${activo}`, import.meta.url), "utf8"),
registrar,
});
try {
await escuchar(servidor, puerto.valor);
} catch (error: unknown) {
fallarArranque(
"arranque-fallido",
error instanceof Error ? error.message : "falla desconocida",
);
return;
}
registrar({ evento: "escuchando", detalle: `http://127.0.0.1:${puertoDe(servidor)}` });
let cierre: Promise<void> | undefined;
const detener = (senal: string): void => {
cierre ??= (async () => {
registrar({ evento: "cierre", detalle: `${senal} recibida` });
await cerrar(servidor);
registrar({ evento: "cerrado", detalle: "el servidor dejó de aceptar conexiones" });
})().catch((error: unknown) => {
fallarArranque(
"cierre-fallido",
error instanceof Error ? error.message : "falla desconocida",
);
});
};
process.once("SIGTERM", () => detener("SIGTERM"));
process.once("SIGINT", () => detener("SIGINT"));
}
await main();
Тестовете са това, което подкрепя твърдението „таблото работи“, без да се отваря браузър. Добре е да прочетеш какво доказва всеки и какво не.
Panel.test.tsx монтира компонента наистина: инсталира симулиран документ на jsdom (dom-de-prueba.ts, както във фигура 2), монтира Panel с createRoot вътре в act и проверява четири неща. Че се вижда „Cargando…“, докато обещанието на cargar е още висящо, и че при разрешаването му се появяват двата реда с текста им. Че detalle с HTML се показва като текст: търси елемент img и не намира нито един и проверява, че получаваният HTML съдържа <img. Че грешка на API се показва с role="alert" и че натискането на „Actualizar“ (истински клик върху бутона, вътре в act) пита отново и възстановява списъка. И че при демонтиране на дървото сигналът, получен от cargar, остава прекъснат. Това са истинските hooks: ефектът се изпълнява, състоянието се променя и React рисува отново; няма никаква симулирана функция на React.
cargar.test.ts проверява мрежовия слой на таблото срещу локален HTTP сървър, който отговаря това, от което се нуждае всеки случай: валиден отчет, 503 и JSON, който не изпълнява договора.
paquete.test.ts е димният тест на цялото и най-амбициозният. Построява таблото с API на esbuild, в паметта и със същите опции като npm run empaquetar; вдига сървъра на revisor с тези файлове като активи и отчет, който съдържа враждебен detalle (<b>negrita</b>); иска / и проверява, че пристига пълната политика CSP, идентична с точния текст и без нито един unsafe- (да я отпуснеш по недоглеждане превръща теста в червен); изтегля /cliente.js, както би го получил браузър; отваря страницата в симулиран документ, изпълнява този скрипт, който прави fetch към истинското API, и чака да се появи редът. Проверява, че текстът му е буквален, с видими тагове, и че не е създаден никакъв елемент b. Това е пълният път: сървър, страница, пакет, API, валидация, React и екраниране, със същите байтове, които биха пътували към браузър. Едно уточнение за теста: jsdom няма fetch, затова тестът му инсталира такъв, който разрешава относителните пътища спрямо локалния сървър, и тази замяна отхвърля втория аргумент, включително сигнала за отмяна, защото AbortSignal, създаден вътре в jsdom, не е този на Node. Отмяната не се тества тук; тества я Panel.test.tsx, който получава сигнала на Node.
И това, което никой от тези тестове не доказва: че истински браузър изтегля и изпълнява пакета. jsdom реализира DOM, но не е браузър: не прилага CSS, не налага политиката CSP и няма двигател за рисуване. Затова, след построяването, има ръчна проверка, която не се автоматизира и която е добре да направиш веднъж, както е обяснено по-долу.
Тестовете, които вече съществуваха от урок 8, остават: таблицата на leerPuerto, тази на esReportePublico и тези на сървъра. В сървъра се е променило само построяването му, което сега получава leerActivo. Следващият блок показва всички файлове, които не са се променили или са се променили в малка подробност.
// fig09_05/src/panel/dom-de-prueba.ts
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://127.0.0.1/",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
IS_REACT_ACT_ENVIRONMENT: true,
});
export const documento = dom.window.document;
// fig09_05/src/panel/Panel.test.tsx
import { documento } from "./dom-de-prueba.js";
import assert from "node:assert/strict";
import test from "node:test";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReportePublico } from "../contrato.js";
import type { Cargar } from "./cargar.js";
import { Panel } from "./Panel.js";
const reporte: ReportePublico = {
estados: [
{ nombre: "catálogo", tipo: "disponible", codigoHttp: 200, duracionMs: 42 },
{ nombre: "pagos", tipo: "falla", detalle: "<img src=x onerror=alert(1)>" },
],
};
async function montar(cargar: Cargar): Promise<{ contenedor: HTMLElement; desmontar: () => void }> {
const contenedor = documento.createElement("div");
documento.body.append(contenedor);
const raiz = createRoot(contenedor);
await act(async () => {
raiz.render(<Panel cargar={cargar} cadaMs={60_000} />);
});
return {
contenedor,
desmontar: () => {
act(() => raiz.unmount());
contenedor.remove();
},
};
}
test("muestra Cargando mientras la API no responde y luego las filas", async () => {
let responder: (reporte: ReportePublico) => void = () => {};
const cargar: Cargar = () =>
new Promise((resolve) => {
responder = resolve;
});
const { contenedor, desmontar } = await montar(cargar);
assert.equal(contenedor.querySelector("[role=status]")?.textContent, "Cargando…");
await act(async () => {
responder(reporte);
});
const filas = [...contenedor.querySelectorAll("li")].map((fila) => fila.textContent);
assert.deepEqual(filas, [
"catálogo: disponible (HTTP 200, 42 ms)",
"pagos: falla (<img src=x onerror=alert(1)>)",
]);
desmontar();
});
test("un detalle con HTML se muestra como texto y no crea elementos", async () => {
const { contenedor, desmontar } = await montar(async () => reporte);
assert.equal(contenedor.querySelector("img"), null);
assert.match(contenedor.innerHTML, /<img src=x onerror=alert\(1\)>/);
desmontar();
});
test("muestra el error y se recupera al pulsar Actualizar", async () => {
let intentos = 0;
const cargar: Cargar = async () => {
intentos += 1;
if (intentos === 1) {
throw new Error("la API respondió 503");
}
return reporte;
};
const { contenedor, desmontar } = await montar(cargar);
assert.equal(
contenedor.querySelector("[role=alert]")?.textContent,
"No se pudo cargar el reporte: la API respondió 503",
);
await act(async () => {
contenedor.querySelector("button")?.click();
});
assert.equal(contenedor.querySelector("[role=alert]"), null);
assert.equal(contenedor.querySelectorAll("li").length, 2);
assert.equal(intentos, 2);
desmontar();
});
test("al desmontar cancela la solicitud en curso", async () => {
let senalRecibida: AbortSignal | undefined;
const cargar: Cargar = (senal) => {
senalRecibida = senal;
return new Promise(() => {});
};
const { desmontar } = await montar(cargar);
assert.equal(senalRecibida?.aborted, false);
desmontar();
assert.equal(senalRecibida?.aborted, true);
});
// fig09_05/src/panel/cargar.test.ts
import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import test from "node:test";
import { cerrar, escuchar, puertoDe } from "../servidor.js";
import { cargarReporte } from "./cargar.js";
async function servirRespuesta(
codigo: number,
cuerpo: string,
): Promise<{ readonly servidor: Server; readonly base: string }> {
const servidor = createServer((_solicitud, respuesta) => {
respuesta.writeHead(codigo, { "content-type": "application/json; charset=utf-8" });
respuesta.end(cuerpo);
});
await escuchar(servidor, 0);
return { servidor, base: `http://127.0.0.1:${puertoDe(servidor)}` };
}
const casos = [
{
nombre: "devuelve el reporte cuando la API responde con el contrato",
codigo: 200,
cuerpo: '{"estados":[{"nombre":"pagos","tipo":"falla","detalle":"HTTP 503"}]}',
esperado: undefined,
},
{
nombre: "rechaza un código HTTP que no es 2xx",
codigo: 503,
cuerpo: '{"detalle":"error interno"}',
esperado: "la API respondió 503",
},
{
nombre: "rechaza un JSON que no cumple el contrato",
codigo: 200,
cuerpo: '{"estados":[{"nombre":"pagos"}]}',
esperado: "la API no entregó un reporte válido",
},
] as const;
for (const caso of casos) {
test(`cargarReporte: ${caso.nombre}`, async () => {
const { servidor, base } = await servirRespuesta(caso.codigo, caso.cuerpo);
try {
const senal = new AbortController().signal;
if (caso.esperado === undefined) {
const reporte = await cargarReporte(senal, base);
assert.equal(reporte.estados[0]?.nombre, "pagos");
} else {
await assert.rejects(cargarReporte(senal, base), { message: caso.esperado });
}
} finally {
await cerrar(servidor);
}
});
}
// fig09_05/src/panel/paquete.test.ts
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { build } from "esbuild";
import { JSDOM } from "jsdom";
import { aReportePublico } from "../reporte.js";
import { cerrar, crearServidor, escuchar, puertoDe, type Activo } from "../servidor.js";
async function empaquetar(): Promise<Map<string, string>> {
const resultado = await build({
entryPoints: [
fileURLToPath(new URL("../../src/panel/cliente.tsx", import.meta.url)),
fileURLToPath(new URL("../../src/panel/panel.css", import.meta.url)),
],
bundle: true,
minify: true,
format: "iife",
outdir: "salida",
write: false,
logLevel: "silent",
});
return new Map(
resultado.outputFiles.map((archivo) => [archivo.path.split("/").pop() ?? "", archivo.text]),
);
}
async function esperar(condicion: () => boolean): Promise<void> {
for (let intento = 0; intento < 100; intento += 1) {
if (condicion()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("la condición no se cumplió a tiempo");
}
test("el paquete que sirve el servidor pinta el reporte en una página real", async () => {
const archivos = await empaquetar();
const api = crearServidor({
obtenerReporte: async () =>
aReportePublico([
{
servicio: { nombre: "catálogo", url: "https://catalogo.example", timeoutMs: 1500 },
tipo: "falla",
detalle: "<b>negrita</b>",
},
]),
leerActivo: async (activo: Activo) => archivos.get(activo) ?? "",
registrar: () => {},
});
await escuchar(api, 0);
const base = `http://127.0.0.1:${puertoDe(api)}`;
try {
const pagina = await fetch(`${base}/`);
const politica = pagina.headers.get("content-security-policy") ?? "";
assert.equal(
politica,
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
);
assert.doesNotMatch(politica, /unsafe-/);
const script = await (await fetch(`${base}/cliente.js`)).text();
const ventana = new JSDOM(await pagina.text(), { runScripts: "outside-only", url: base })
.window;
Object.assign(ventana, { fetch: (ruta: string) => fetch(new URL(ruta, base)) });
ventana.eval(script);
await esperar(() => ventana.document.querySelector("li") !== null);
assert.equal(
ventana.document.querySelector("li")?.textContent,
"catálogo: falla (<b>negrita</b>)",
);
assert.equal(ventana.document.querySelector("b"), null);
ventana.close();
} finally {
await cerrar(api);
}
});
// fig09_05/src/servidor.test.ts
import assert from "node:assert/strict";
import { createServer, request, type Server } from "node:http";
import test from "node:test";
import type { EntradaBitacora } from "./bitacora.js";
import { esReportePublico } from "./contrato.js";
import { consultarConFetch } from "./consulta.js";
import type { Servicio } from "./modelo.js";
import { aReportePublico } from "./reporte.js";
import { revisarTodos } from "./revisar.js";
import { cerrar, crearServidor, escuchar, puertoDe } from "./servidor.js";
async function destino(): Promise<{ readonly servidor: Server; readonly base: string }> {
const servidor = createServer((solicitud, respuesta) => {
if (solicitud.url === "/ok") {
respuesta.writeHead(200).end("ok");
} else if (solicitud.url === "/caido") {
respuesta.writeHead(503).end("caído");
}
// /lento nunca responde: sirve para provocar el tiempo límite.
});
await escuchar(servidor, 0);
return { servidor, base: `http://127.0.0.1:${puertoDe(servidor)}` };
}
async function puertoCerrado(): Promise<number> {
const servidor = createServer();
await escuchar(servidor, 0);
const puerto = puertoDe(servidor);
await cerrar(servidor);
return puerto;
}
test("GET /api/estados revisa destinos reales y publica el reporte", async () => {
const { servidor: remoto, base } = await destino();
const sinServicio = await puertoCerrado();
const servicios: readonly Servicio[] = [
{ nombre: "catálogo", url: `${base}/ok`, timeoutMs: 1500 },
{ nombre: "pagos", url: `${base}/caido`, timeoutMs: 1500 },
{ nombre: "inventario", url: `http://127.0.0.1:${sinServicio}/`, timeoutMs: 1500 },
{ nombre: "reportes", url: `${base}/lento`, timeoutMs: 150 },
];
const entradas: EntradaBitacora[] = [];
const api = crearServidor({
obtenerReporte: async () => aReportePublico(await revisarTodos(servicios, consultarConFetch)),
leerActivo: async () => "",
registrar: (entrada) => entradas.push(entrada),
});
await escuchar(api, 0);
try {
const respuesta = await fetch(`http://127.0.0.1:${puertoDe(api)}/api/estados?orden=nombre`);
assert.equal(respuesta.status, 200);
assert.equal(respuesta.headers.get("content-type"), "application/json; charset=utf-8");
const cuerpo: unknown = await respuesta.json();
assert.ok(esReportePublico(cuerpo));
assert.deepEqual(
cuerpo.estados.map((estado) =>
estado.tipo === "falla"
? [estado.nombre, estado.detalle]
: [estado.nombre, estado.codigoHttp],
),
[
["catálogo", 200],
["pagos", "HTTP 503"],
["inventario", "conexión rechazada"],
["reportes", "tiempo límite agotado"],
],
);
assert.equal(JSON.stringify(cuerpo).includes("url"), false);
assert.match(entradas[0]?.detalle ?? "", /^GET \/api\/estados 200 \d+ ms$/);
} finally {
await cerrar(api);
remoto.closeAllConnections();
await cerrar(remoto);
}
});
test("las rutas desconocidas, los métodos y los errores internos responden con su código", async () => {
const entradas: EntradaBitacora[] = [];
const api = crearServidor({
obtenerReporte: async () => {
throw new Error("detalle interno que no debe salir");
},
leerActivo: async () => "",
registrar: (entrada) => entradas.push(entrada),
});
await escuchar(api, 0);
const base = `http://127.0.0.1:${puertoDe(api)}`;
try {
const salud = await fetch(`${base}/salud`);
assert.equal(salud.status, 200);
assert.equal(await salud.text(), "ok");
const desconocida = await fetch(`${base}/api/no-existe`);
assert.equal(desconocida.status, 404);
assert.deepEqual(await desconocida.json(), { detalle: "ruta no encontrada" });
const metodo = await fetch(`${base}/api/estados`, { method: "POST" });
assert.equal(metodo.status, 405);
assert.equal(metodo.headers.get("allow"), "GET");
await metodo.body?.cancel();
const interno = await fetch(`${base}/api/estados`);
assert.equal(interno.status, 500);
assert.deepEqual(await interno.json(), { detalle: "error interno" });
assert.ok(entradas.some((entrada) => entrada.detalle === "detalle interno que no debe salir"));
} finally {
await cerrar(api);
}
});
test("una ruta que no se puede analizar responde 404 y no derriba el servidor", async () => {
const api = crearServidor({
obtenerReporte: async () => ({ estados: [] }),
leerActivo: async () => "",
registrar: () => {},
});
await escuchar(api, 0);
const puerto = puertoDe(api);
try {
const codigo = await new Promise<number>((resolve, reject) => {
const solicitud = request({ host: "127.0.0.1", port: puerto, path: "//" }, (respuesta) => {
respuesta.resume();
resolve(respuesta.statusCode ?? 0);
});
solicitud.on("error", reject);
solicitud.end();
});
assert.equal(codigo, 404);
assert.equal((await fetch(`http://127.0.0.1:${puerto}/salud`)).status, 200);
} finally {
await cerrar(api);
}
});
// fig09_05/src/contrato.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { esReportePublico } from "./contrato.js";
const casos: readonly {
readonly nombre: string;
readonly valor: unknown;
readonly valido: boolean;
}[] = [
{
nombre: "un reporte con las dos variantes",
valor: {
estados: [
{ nombre: "catálogo", tipo: "disponible", codigoHttp: 200, duracionMs: 42 },
{ nombre: "pagos", tipo: "falla", detalle: "tiempo límite agotado" },
],
},
valido: true,
},
{ nombre: "null", valor: null, valido: false },
{ nombre: "estados no es un arreglo", valor: { estados: "ninguno" }, valido: false },
{
nombre: "un estado con un tipo desconocido",
valor: { estados: [{ nombre: "pagos", tipo: "pendiente" }] },
valido: false,
},
{
nombre: "codigoHttp llega como texto",
valor: {
estados: [{ nombre: "pagos", tipo: "disponible", codigoHttp: "200", duracionMs: 42 }],
},
valido: false,
},
];
for (const caso of casos) {
test(`esReportePublico: ${caso.nombre}`, () => {
assert.equal(esReportePublico(caso.valor), caso.valido);
});
}
// fig09_05/src/configuracion.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { leerPuerto } from "./configuracion.js";
const casos: readonly {
readonly nombre: string;
readonly entrada: string | undefined;
readonly esperado: ReturnType<typeof leerPuerto>;
}[] = [
{ nombre: "sin variable usa 3000", entrada: undefined, esperado: { ok: true, valor: 3000 } },
{ nombre: "un puerto válido", entrada: "8080", esperado: { ok: true, valor: 8080 } },
{ nombre: "65535 es el límite", entrada: "65535", esperado: { ok: true, valor: 65535 } },
{
nombre: "65536 se pasa del límite",
entrada: "65536",
esperado: { ok: false, detalle: "PUERTO debe estar entre 1 y 65535" },
},
{
nombre: "0 no es un puerto",
entrada: "0",
esperado: { ok: false, detalle: "PUERTO debe ser un entero positivo" },
},
{
nombre: "un decimal se rechaza",
entrada: "12.5",
esperado: { ok: false, detalle: "PUERTO debe ser un entero positivo" },
},
{
nombre: "texto se rechaza",
entrada: "hola",
esperado: { ok: false, detalle: "PUERTO debe ser un entero positivo" },
},
{
nombre: "la cadena vacía se rechaza",
entrada: "",
esperado: { ok: false, detalle: "PUERTO debe ser un entero positivo" },
},
];
for (const caso of casos) {
test(`leerPuerto: ${caso.nombre}`, () => {
assert.deepEqual(leerPuerto(caso.entrada), caso.esperado);
});
}
// fig09_05/src/reporte.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import type { Estado } from "./modelo.js";
import { aReportePublico, lineaReporte } from "./reporte.js";
const servicio = { nombre: "catálogo", url: "https://catalogo.example", timeoutMs: 1500 };
test("disponible conserva código y duración", () => {
assert.equal(
lineaReporte({ servicio, tipo: "disponible", codigoHttp: 204, duracionMs: 18 }),
"catálogo: HTTP 204 en 18 ms",
);
});
test("falla conserva detalle", () => {
assert.equal(
lineaReporte({ servicio, tipo: "falla", detalle: "conexión rechazada" }),
"catálogo: falla (conexión rechazada)",
);
});
test("el reporte público no publica la URL ni el tiempo límite", () => {
const estados: readonly Estado[] = [
{ servicio, tipo: "disponible", codigoHttp: 200, duracionMs: 42 },
{ servicio, tipo: "falla", detalle: "tiempo límite agotado" },
];
assert.deepEqual(aReportePublico(estados), {
estados: [
{ nombre: "catálogo", tipo: "disponible", codigoHttp: 200, duracionMs: 42 },
{ nombre: "catálogo", tipo: "falla", detalle: "tiempo límite agotado" },
],
});
});
И файловете от урок 8, които остават същите: журналът, заявката с fetch, четецът на файла с услугите, конфигурацията, отчетът, моделът, координаторът revisarTodos, .prettierrc и servicios.json.
// fig09_05/src/bitacora.ts
export interface EntradaBitacora {
readonly evento: string;
readonly detalle: string;
}
export type Bitacora = (entrada: EntradaBitacora) => void;
export const bitacoraEnConsola: Bitacora = (entrada) => {
console.log(JSON.stringify({ momento: new Date().toISOString(), ...entrada }));
};
// fig09_05/src/consulta.ts
import type { Consultar } from "./revisar.js";
function codigoDeRed(error: unknown): string | undefined {
if (
error instanceof Error &&
error.cause instanceof Error &&
"code" in error.cause &&
typeof error.cause.code === "string"
) {
return error.cause.code;
}
return undefined;
}
export const consultarConFetch: Consultar = async (servicio, senal) => {
const inicio = performance.now();
try {
const respuesta = await fetch(servicio.url, { signal: senal });
await respuesta.body?.cancel();
return {
codigoHttp: respuesta.status,
duracionMs: Math.round(performance.now() - inicio),
};
} catch (error: unknown) {
if (senal.aborted) {
throw new Error("tiempo límite agotado", { cause: error });
}
if (codigoDeRed(error) === "ECONNREFUSED") {
throw new Error("conexión rechazada", { cause: error });
}
throw new Error("no se pudo conectar", { cause: error });
}
};
// fig09_05/src/archivo.ts
import { readFile } from "node:fs/promises";
import { leerServicios, type Resultado } from "./configuracion.js";
import type { Servicio } from "./modelo.js";
export async function leerServiciosDeArchivo(
ruta: string,
): Promise<Resultado<readonly Servicio[]>> {
let texto: string;
try {
texto = await readFile(ruta, "utf8");
} catch {
return { ok: false, detalle: `no se pudo leer ${ruta}` };
}
let documento: unknown;
try {
documento = JSON.parse(texto);
} catch {
return { ok: false, detalle: `${ruta} no contiene JSON válido` };
}
return leerServicios(documento);
}
// fig09_05/src/configuracion.ts
import type { Servicio } from "./modelo.js";
export type Resultado<T> = { ok: true; valor: T } | { ok: false; detalle: string };
export function esRegistro(valor: unknown): valor is Record<string, unknown> {
return typeof valor === "object" && valor !== null && !Array.isArray(valor);
}
function leerServicio(valor: unknown): Resultado<Servicio> {
if (!esRegistro(valor)) {
return { ok: false, detalle: "cada servicio debe ser un objeto" };
}
const { nombre, url, timeoutMs } = valor;
if (
typeof nombre !== "string" ||
nombre.trim() === "" ||
typeof url !== "string" ||
url.trim() === "" ||
typeof timeoutMs !== "number" ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs <= 0
) {
return { ok: false, detalle: "servicio incompleto o inválido" };
}
return { ok: true, valor: { nombre, url, timeoutMs } };
}
export function leerServicios(valor: unknown): Resultado<readonly Servicio[]> {
if (!Array.isArray(valor)) {
return { ok: false, detalle: "la configuración debe ser un arreglo" };
}
const servicios: Servicio[] = [];
for (const [indice, entrada] of valor.entries()) {
const resultado = leerServicio(entrada);
if (!resultado.ok) {
return { ok: false, detalle: `servicio ${indice + 1}: ${resultado.detalle}` };
}
servicios.push(resultado.valor);
}
return { ok: true, valor: servicios };
}
export function leerEnteroPositivo(
nombre: string,
valor: string | undefined,
predeterminado: number,
): Resultado<number> {
if (valor === undefined) {
return { ok: true, valor: predeterminado };
}
if (!/^[1-9]\d*$/.test(valor)) {
return { ok: false, detalle: `${nombre} debe ser un entero positivo` };
}
const numero = Number(valor);
if (!Number.isSafeInteger(numero)) {
return { ok: false, detalle: `${nombre} está fuera del rango seguro` };
}
return { ok: true, valor: numero };
}
export function leerPuerto(valor: string | undefined): Resultado<number> {
const puerto = leerEnteroPositivo("PUERTO", valor, 3000);
if (puerto.ok && puerto.valor > 65535) {
return { ok: false, detalle: "PUERTO debe estar entre 1 y 65535" };
}
return puerto;
}
// fig09_05/src/reporte.ts
import type { EstadoPublico, ReportePublico } from "./contrato.js";
import type { Estado } from "./modelo.js";
export function lineaReporte(estado: Estado): string {
if (estado.tipo === "disponible") {
return `${estado.servicio.nombre}: HTTP ${estado.codigoHttp} en ${estado.duracionMs} ms`;
}
return `${estado.servicio.nombre}: falla (${estado.detalle})`;
}
function aEstadoPublico(estado: Estado): EstadoPublico {
if (estado.tipo === "disponible") {
return {
nombre: estado.servicio.nombre,
tipo: "disponible",
codigoHttp: estado.codigoHttp,
duracionMs: estado.duracionMs,
};
}
return { nombre: estado.servicio.nombre, tipo: "falla", detalle: estado.detalle };
}
export function aReportePublico(estados: readonly Estado[]): ReportePublico {
return { estados: estados.map(aEstadoPublico) };
}
// fig09_05/src/modelo.ts
export interface Servicio {
readonly nombre: string;
readonly url: string;
readonly timeoutMs: number;
}
export type Estado =
| {
readonly servicio: Servicio;
readonly tipo: "disponible";
readonly codigoHttp: number;
readonly duracionMs: number;
}
| {
readonly servicio: Servicio;
readonly tipo: "falla";
readonly detalle: string;
};
// fig09_05/src/revisar.ts
import type { Estado, Servicio } from "./modelo.js";
export type Respuesta = { readonly codigoHttp: number; readonly duracionMs: number };
export type Consultar = (servicio: Servicio, senal: AbortSignal) => Promise<Respuesta>;
export async function revisarTodos(
servicios: readonly Servicio[],
consultar: Consultar,
): Promise<readonly Estado[]> {
return Promise.all(
servicios.map(async (servicio) => {
try {
const respuesta = await consultar(servicio, AbortSignal.timeout(servicio.timeoutMs));
if (respuesta.codigoHttp >= 200 && respuesta.codigoHttp < 300) {
return { servicio, tipo: "disponible", ...respuesta };
}
return { servicio, tipo: "falla", detalle: `HTTP ${respuesta.codigoHttp}` };
} catch (error: unknown) {
return {
servicio,
tipo: "falla",
detalle: error instanceof Error ? error.message : "falla desconocida",
};
}
}),
);
}
{
"singleQuote": false,
"printWidth": 100
}
[
{ "nombre": "ejemplo", "url": "https://example.com", "timeoutMs": 3000 },
{ "nombre": "node", "url": "https://nodejs.org", "timeoutMs": 3000 },
{ "nombre": "local-apagado", "url": "http://127.0.0.1:8099", "timeoutMs": 1000 }
]
$ cd fig09_05
$ npm run verificar
> verificar
> tsc --noEmit
$ npm run lint
> lint
> eslint src
$ npm run formato
> formato
> prettier --check src
Checking formatting...
All matched files use Prettier code style!
$ npm run probar
> probar
> npm run compilar && node --test "dist/**/*.test.js"
> compilar
> tsc
✔ leerPuerto: sin variable usa 3000 (0.5965ms)
✔ leerPuerto: un puerto válido (0.069125ms)
✔ leerPuerto: 65535 es el límite (0.053416ms)
✔ leerPuerto: 65536 se pasa del límite (0.111167ms)
✔ leerPuerto: 0 no es un puerto (0.076833ms)
✔ leerPuerto: un decimal se rechaza (0.056958ms)
✔ leerPuerto: texto se rechaza (0.077583ms)
✔ leerPuerto: la cadena vacía se rechaza (0.061083ms)
✔ esReportePublico: un reporte con las dos variantes (0.642125ms)
✔ esReportePublico: null (0.076959ms)
✔ esReportePublico: estados no es un arreglo (0.1285ms)
✔ esReportePublico: un estado con un tipo desconocido (0.741875ms)
✔ esReportePublico: codigoHttp llega como texto (0.060958ms)
✔ muestra Cargando mientras la API no responde y luego las filas (18.194375ms)
✔ un detalle con HTML se muestra como texto y no crea elementos (3.132041ms)
✔ muestra el error y se recupera al pulsar Actualizar (4.856ms)
✔ al desmontar cancela la solicitud en curso (1.096125ms)
✔ cargarReporte: devuelve el reporte cuando la API responde con el contrato (21.967625ms)
✔ cargarReporte: rechaza un código HTTP que no es 2xx (8.867708ms)
✔ cargarReporte: rechaza un JSON que no cumple el contrato (3.011083ms)
✔ el paquete que sirve el servidor pinta el reporte en una página real (142.25275ms)
✔ disponible conserva código y duración (0.3745ms)
✔ falla conserva detalle (0.051708ms)
✔ el reporte público no publica la URL ni el tiempo límite (0.32275ms)
✔ GET /api/estados revisa destinos reales y publica el reporte (178.837542ms)
✔ las rutas desconocidas, los métodos y los errores internos responden con su código (10.282458ms)
✔ una ruta que no se puede analizar responde 404 y no derriba el servidor (5.185333ms)
ℹ tests 27
ℹ suites 0
ℹ pass 27
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 532.285292
$ npm run empaquetar
> empaquetar
> esbuild src/panel/cliente.tsx src/panel/panel.css --bundle --minify --format=iife --log-level=warning --outdir=dist/publico
За да видиш таблото в истински браузър, построй и стартирай. npm run empaquetar оставя таблото в dist/publico/:
$ npm run compilar
$ npm run empaquetar
$ wc -c dist/publico/*
225635 dist/publico/cliente.js
249 dist/publico/panel.css
225884 total
$ PUERTO=3100 npm run arrancar
{"momento":"2026-10-02T21:48:15.755Z","evento":"escuchando","detalle":"http://127.0.0.1:3100"}
Отвори http://127.0.0.1:3100/ в браузъра си. Трябва да видиш заглавието „Revisor“, „Cargando…“, което трае по-малко от секунда, и списък с по една услуга на ред, със зелена рамка за наличните и червена за тези, които се провалят; на всеки десет секунди списъкът се обновява сам, а бутонът „Actualizar“ го презарежда веднага. Отвори инструментите за разработка (F12), раздела за мрежата, и потвърди заявка GET /api/estados със състояние 200 всеки път; в раздела за конзолата не трябва да има грешки, а в заглавните части на отговора на / трябва да се появи content-security-policy. Ако нещо се провали там, журналът на сървъра, в първия терминал, има по един ред за всяка заявка.
Със сървъра в действие така отговаря всеки маршрут на страницата:
$ curl -i http://127.0.0.1:3100/
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-content-type-options: nosniff
content-security-policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
...
$ curl -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:3100/cliente.js
200 text/javascript; charset=utf-8
$ curl -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:3100/etc/passwd
404 application/json; charset=utf-8
Този последен ред е доказателството за обхождането на пътища: да поискаш файл, който програмата никога не е обещала да предаде, дава 404, а не файл.
Компилиране и публикуване: известен артефакт
„Да публикуваш“ значи различни неща във всяка организация, така че няма универсална честна команда. Универсален е редът: да построиш известен артефакт, да го инспектираш, да инсталираш само необходимото за изпълнението му, да конфигурираш средата извън хранилището и да стартираш това, което си построил. Артефактът е идентифицируемият изход, който се предава за изпълнение. Този урок подготвя и проверява този артефакт; внедряването във твоята инфраструктура остава извън това, което може да се каже по общ начин.
Артефактът на revisor е папка с четири неща: dist/ (компилираният сървър и dist/publico/ с таблото), package.json (от който Node има нужда, за да знае, че .js файловете в dist/ са ESM модули, чрез полето му "type": "module"), package-lock.json (точното разрешаване на зависимостите) и servicios.json (конфигурацията). Не носи src/, нито node_modules/ от разработката. dist/ съдържа и компилираните тестове, които никой не изпълнява в продукция: не пречат, а ако искаш по-строг артефакт, можеш да ги изключиш в отделна конфигурация за компилация. А тъй като таблото е пакетирано вътре в cliente.js, а сървърът използва само модули на Node, артефактът не се нуждае от никаква зависимост: това, което се инсталира в продукция, е нищо.
За да го провериш, не се доверявай на логиката: построй го и го изпълни в чиста папка, което е най-близкото до нов сървър. От корена на revisor/:
$ npm run compilar && npm run empaquetar
$ mkdir ../revisor-artefacto
$ cp -R dist package.json package-lock.json servicios.json ../revisor-artefacto/
$ cd ../revisor-artefacto
$ npm ci --omit=dev
up to date, audited 1 package in 113ms
found 0 vulnerabilities
$ PUERTO=3100 node dist/main.js
{"momento":"2026-10-02T21:48:15.755Z","evento":"escuchando","detalle":"http://127.0.0.1:3100"}
npm ci инсталира точно това, което казва package-lock.json, проваля се, ако заключващият файл и package.json се разминават, и изтрива node_modules/ преди да започне: това е инсталацията, предвидена за доставки, за разлика от npm install, която може да разреши нови версии. --omit=dev пропуска зависимостите за разработка. Резултатът, „audited 1 package“, е самият проект: никаква производствена зависимост. Ако беше поставил react като обикновена зависимост, би се инсталирал напразно; ако сървърът импортираше нещо, което е само в devDependencies, тази стъпка е мястото, където би се провалил, и по-добре да се провали тук, отколкото на производствения сървър.
Преди да предадеш артефакта, изпълни проверките в този ред, от чиста инсталация с npm ci: npm run verificar, npm run lint, npm run formato, npm run probar, npm run empaquetar. Прегледай package-lock.json като част от промяната, защото записва това, което ще се изпълнява. Не публикувай node_modules/, файлове .env, журнали, нито примери с истински адреси, пароли или токени; и забележи, че servicios.json се чете от папката, откъдето стартираш процеса, така че услугата трябва да се стартира с тази папка като работна директория.
Има четири решения, които артефактът не взима вместо теб. Първо: сървърът слуша само на 127.0.0.1, локалния интерфейс, и това е нарочно, защото не бива да се излага директно в Интернет. Обичайното е да се постави отпред обратен прокси (reverse proxy), процес, който приема публичните връзки, прекратява HTTPS шифроването, TLS, и препраща заявката към revisor по локалния интерфейс; този прокси, а не програмата, е който представя сертификата. Второ: надзорник, който стартира процеса, рестартира го, ако падне, и му изпраща SIGTERM, за да го спре, което е причината коректното спиране от урок 8 да има значение. Трето: достъп. Публично табло може да е такова, ако показва само публична информация; такова, което разкрива какви системи имаш и как се провалят, вероятно изисква автентикация и това не се решава със скриване на URL: един маршрут не става частен, защото не е свързан от никъде. Определи границата, преди да публикуваш, и тествай отговорите без валидна сесия. Четвърто: не използвай нищо, което е за разработка, като автоматично презареждане или подробни съобщения, като че ли е крайният пакет.
Грешката, която ще видиш
React не измисля нов клас грешки на типове; грешките на един компонент са тези на всяко извикване, с формата на свойствата. Първата се появява, когато предадеш свойство, което не принадлежи към обединението на договора, а втората, когато забравиш задължително свойство. С TypeScript 7.0.2 tsc съобщава и двете в един и същ файл. Нито една от двете не е проблем на React: състоянието "pendiente" не принадлежи към обединението, което таблото обещава да обслужва, а FilaEstado без своето estado няма какво да нарисува.
// fig09_06.tsx
type EstadoPublico =
| { readonly nombre: string; readonly tipo: "disponible"; readonly codigoHttp: number }
| { readonly nombre: string; readonly tipo: "falla"; readonly detalle: string };
function FilaEstado({ estado }: { readonly estado: EstadoPublico }) {
return <li>{estado.nombre}</li>;
}
export const pantalla = (
<ul>
<FilaEstado estado={{ nombre: "pagos", tipo: "pendiente" }} />
<FilaEstado />
</ul>
);
$ npx tsc --strict --target ES2022 --module nodenext --jsx react-jsx fig09_06.tsx
fig09_06.tsx(12,44): error TS2322: Type '"pendiente"' is not assignable to type '"disponible" | "falla"'.
fig09_06.tsx(13,6): error TS2741: Property 'estado' is missing in type '{}' but required in type '{ readonly estado: EstadoPublico; }'.
TS2322 казва, че стойност не може да се присвои на типа, който свойството очаква: тук, че "pendiente" не е нито една от двете стойности на tipo. Не го поправяй с as EstadoPublico: това твърдение би заглушило точно предупреждението, което не ти позволява да нарисуваш състояние, което никой компонент не умее да рисува. Ако „pendiente“ е истинско състояние на домейна, добави го към обединението на contrato.ts, към защитата esEstadoPublico, към преобразувателя на сървъра и към FilaEstado; компилаторът ще ти казва къде липсва всяка част. TS2741 казва, че липсва задължително свойство; чети го като въпрос: този ред трябва ли да съществува без състояние? Почти винаги отговорът е, че компонентът е извикан неправилно.
Има грешка, която не е на компилатора и която ще срещнеш: да стартираш сървъра, без да си пакетирал таблото. Сървърът отговаря със страницата, но GET /cliente.js връща 500 и журналът казва какво липсва:
$ curl -i http://127.0.0.1:3101/cliente.js
HTTP/1.1 500 Internal Server Error
...
{"detalle":"error interno"}
И в терминала на сървъра:
{"momento":"2026-10-02T21:48:27.838Z","evento":"error","detalle":"ENOENT: no such file or directory, open '/home/tu-usuario/proyectos/revisor/dist/publico/cliente.js'"}
{"momento":"2026-10-02T21:48:27.839Z","evento":"solicitud","detalle":"GET /cliente.js 500 1 ms"}
ENOENT значи „този файл не съществува“. Решението не е да създадеш празен файл: е да изпълниш npm run empaquetar. Екранът междувременно остава празен, а инструментите за разработка показват скрипта /cliente.js провален в червено. Това е добър пример защо грешката се записва на сървъра, а не само се отговаря: без журнала браузърът би казал само „зареждането се провали“.
И трети случай, този от браузъра: ако в конзолата видиш Refused to execute inline script because it violates the following Content Security Policy directive, политиката върши работата си. Нещо се е опитало да изпълни скрипт вътре в реда или да зареди ресурс от друг произход. Не отпускай политиката с 'unsafe-inline', за да изчезне предупреждението: разбери какво се е опитало да се изпълни, което почти винаги е знак, че нещо не бива да е там.
Какво се прави погрешно
Да вмъкваш чужд HTML без санитизиране.
dangerouslySetInnerHTMLилиelemento.innerHTML = textoс текст, който не контролираш, превръщат данни в инструкции: това е XSS. Показвай текст като дете на JSX, а ако наистина ти трябва чужд HTML, мини го през поддържан санитизатор, преди да рисуваш. Проектът забранява и двете форми с ESLint и ограничава какво може да се изпълнява с политика CSP.Да пишеш домашна функция, която да „почиства“ HTML. Да заменяш
<script>или списък от думи оставя да минат атрибути за събития, URLjavascript:, SVG и други кодирания. Списък на забраненото винаги е непълен; поддържаният санитизатор тръгва от списък на разрешеното.Да третираш JSON от мрежата, като че ли е типът.
(await respuesta.json()) as ReportePublicoсе компилира и не проверява нищо. Получавайunknown, валидирай със споделената защита и проверявайrespuesta.ok, преди да парснеш.Да превръщаш грешка на API в празен списък. Табло, което показва „няма услуги“, когато API е върнало
503, скрива отказа точно там, където някой гледа. Състоянието за грешка съществува, за да казва екранът какво се е случило.Да искаш данни в тялото на компонента.
fetchизвънuseEffectсе повтаря при всяко рисуване, а тъй като отговорът променя състоянието, предизвиква ново рисуване: цикъл от заявки. Заявките са ефекти и отиват вuseEffect.Да забравиш почистването на ефекта. Без да прекъснеш заявката и да спреш таймера при демонтиране, закъснелите отговори се опитват да обновяват компоненти, които вече не съществуват, а интервалите продължават да работят завинаги.
Да споделяш вътрешния модел вместо публичния договор. Ако таблото импортира
Estado, URL на всяка услуга пътува към браузъра по удобство. Споделяйcontrato.ts: това, което пресича границата, а не това, което има вътре.Да импортираш код на Node в споделен файл.
import "node:fs"вcontrato.tsразваля пакетирането на таблото или, още по-лошо, занася към браузъра код, който не бива да напуска сървъра. Споделеното съдържа само типове и чисти функции.Да бъркаш пакетирането с проверката.
esbuildизтрива типовете, без да ги проверява. Процес, който само пакетира, може да предаде програма с грешки в типовете;npm run verificarостава задължителен.Да сервираш файлове с пътя, който пише клиентът. Да съставиш
readFile("dist/publico" + url)позволява да се поиска/../../secreto. Със затворен списък от известни активи, катоActivo, тази атака няма откъде да влезе.Да отпускаш политиката CSP с
'unsafe-inline'при първото предупреждение. Това е еквивалентът на изключване на аларма, защото звъни: губиш защитата точно когато е работела. Разбери какво се е опитало да се изпълни.Да публикуваш работната директория.
node_modules/,src/,.envи журнали не са част от артефакта. Построй, копирай само необходимото и инсталирай сnpm ci --omit=dev.
Упражнения
Упражнение 1 — Трети начин да видиш отчета
Добави към таблото резюме над списъка: „2 от 3 услуги са налични“. Изчисли го в чиста функция resumir(reporte: ReportePublico): string в собствен файл, тествай я с таблица от случаи (нито една налична, всички, смес, празен списък) и я използвай от Contenido. Потвърди с npm run probar, че тестът на Panel продължава да минава, и добави твърдение, което проверява резюмето на екрана.
Упражнение 2 — Прегледай какво пресича API
Добави към Servicio поле responsable: string (например имейл за контакт) и към servicios.json стойността за всяка услуга, без да пипаш contrato.ts. Изпълни npm run verificar и обясни кои файлове трябваше да промениш, за да се компилира. После стартирай revisor и потвърди с curl http://127.0.0.1:3100/api/estados, че отговорният не се появява в отговора. Обясни какво би станало, ако API сериализираше директно Estado.
Упражнение 3 — Валидирай, преди да рисуваш
Добави към договора незадължително поле detalle към наличните състояния, например за да предупреждава за бавни отговори, и обнови esEstadoPublico, така че да го приема само ако е текст. Напиши два нови случая в таблицата на contrato.test.ts: един валиден и един с числов detalle. Провери, че таблото показва подробността на налично състояние, без FilaEstado да използва твърдение.
Упражнение 4 — Табло, което не остарява мълчаливо
Ако API спре да отговаря, таблото показва грешката, но губи списъка, който вече е имало. Промени useReporte така, че когато обновяване се провали и вече е имало списък, да запази последния списък заедно с предупреждението за грешка. Размисли коя алтернатива на Carga ти трябва да добавиш, добави тест в Panel.test.tsx, който го проверява, и потвърди, че компилаторът ти сочи switch на Contenido, докато не обслужиш новата алтернатива.
Решения
Решение 1
Изчислението е чиста функция, която не знае за React: получава договора и връща текст. Това позволява да се тества с данни, построени в паметта, и е това, което Contenido използва, без допълнителна логика.
// src/panel/resumir.ts
import type { ReportePublico } from "../contrato.js";
export function resumir(reporte: ReportePublico): string {
const disponibles = reporte.estados.filter((estado) => estado.tipo === "disponible").length;
return `${disponibles} de ${reporte.estados.length} servicios disponibles`;
}
В Contenido клонът listo рисува <p>{resumir(carga.reporte)}</p> преди списъка. В теста на компонента твърдението е assert.equal(contenedor.querySelector("p")?.textContent, "1 de 2 servicios disponibles") с примерния отчет, който има една налична услуга и една в отказ. Празният списък заслужава своя случай: 0 de 0 servicios disponibles е правилна фраза, но реши дали предпочиташ друг текст, преди да го види човек.
Решение 2
Добавянето на responsable към Servicio проваля компилацията на всяко място, което изгражда Servicio без него: leerServicio от configuracion.ts, който трябва да чете и валидира полето със същите защити като останалите, и тестовете и фигурите, които пишат услуги на ръка. contrato.ts, aReportePublico и таблото не се променят и в това е смисълът на упражнението: тъй като aEstadoPublico изгражда обекта поле по поле, новото поле не стига до отговора. Ако API сериализираше Estado с JSON.stringify, responsable би пътувал към всеки браузър, без никой да го е решил; това би било изтичане на лични данни, причинено от добавянето на една колона.
Решение 3
Полето е незадължително в типа, а защитата го изисква само когато присъства: „незадължително“ значи, че може да липсва, а не че може да има каква да е стойност.
// src/contrato.ts (fragmento)
| {
readonly nombre: string;
readonly tipo: "disponible";
readonly codigoHttp: number;
readonly duracionMs: number;
readonly detalle?: string;
}
// en esEstadoPublico, rama "disponible":
return (
typeof valor.codigoHttp === "number" &&
typeof valor.duracionMs === "number" &&
(valor.detalle === undefined || typeof valor.detalle === "string")
);
В FilaEstado клонът disponible добавя {estado.detalle === undefined ? null : — ${estado.detalle}} след продължителността. Това е нормално стесняване: вътре в клона, който не е undefined, estado.detalle е string, без никакво твърдение. Двата случая в таблицата са отчет с detalle: "lento" в налично състояние, който трябва да е валиден, и друг с detalle: 7, който трябва да се отхвърли.
Решение 4
Липсващата алтернатива е зареждане със списък и предупреждение едновременно: { tipo: "obsoleto"; reporte: ReportePublico; detalle: string }. Това е единственият начин да се представи „имам стари данни и последният опит се провали“, без да измисляш две променливи, които могат да си противоречат.
// en useReporte, dentro de pedir():
} catch (error: unknown) {
if (control.signal.aborted) {
return;
}
const detalle = error instanceof Error ? error.message : "falló la carga";
establecerCarga((actual) =>
actual.tipo === "listo" || actual.tipo === "obsoleto"
? { tipo: "obsoleto", reporte: actual.reporte, detalle }
: { tipo: "error", detalle },
);
}
Две подробности. Когато следващото обновяване успее, establecerCarga({ tipo: "listo", reporte }) отхвърля предупреждението. И тъй като Contenido прави switch със защитата never, компилаторът ти сочи (TS2322) тази функция, докато не нарисуваш клона obsoleto: списъка и <p role="alert"> с предупреждението. Това е ползата да моделираш състоянията на екрана като обединение.
Как разбирам, че съм успял
-
npx tsc --versionотпечатваVersion 7.0.2вътре в~/proyectos/revisor. - В
figuras/fig09_02.tsxотпечатваprimer render: <p>Cargando…</p>и послеtras el efecto: <p>2 servicios</p>. -
fig09_03.tsxотпечатва тага, екраниран с<и>, аfig09_04.tsx, сdangerouslySetInnerHTML, го отпечатва без екраниране. - В проекта
npm run verificar,npm run lintиnpm run formatoзавършват без предупреждения, аnpm run probarдокладва 27 одобрени теста и 0 неуспешни. - Когато добавиш файл с
dangerouslySetInnerHTML,npm run lintсе проваля със съобщението на правилото; когато го изтриеш, отново минава. -
npm run empaquetarзавършва без изход и оставяcliente.jsиpanel.cssвdist/publico/. - С
PUERTO=3100 npm run arrancar, отварянето наhttp://127.0.0.1:3100/в браузър показва списъка, „Actualizar“ го презарежда, а разделът за мрежата показваGET /api/estadosсъс състояние 200. -
curl -i http://127.0.0.1:3100/включва заглавната частcontent-security-policy, аcurl http://127.0.0.1:3100/etc/passwdотговаря 404. - В чиста папка само с
dist/,package.json,package-lock.jsonиservicios.json,npm ci --omit=devзавършва успешно, аnode dist/main.jsстартира същия сървър.
За допълнително четене
React: Learn React — официална документация за компонентите, свойствата, състоянието и ефектите, с раздел за TypeScript; консултирано на 2 октомври 2026 г.
React: Synchronizing with Effects — официална документация за
useEffect: кога да се използва, списъкът със зависимости и функцията за почистване; консултирано на 2 октомври 2026 г.OWASP: Cross Site Scripting Prevention Cheat Sheet — наръчник на OWASP за предотвратяване на XSS, с правила според контекста на изхода; консултирано на 2 октомври 2026 г.
MDN: Content Security Policy (CSP) — справочник за политиката за сигурност на съдържанието и нейните директиви; консултирано на 2 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx