Съдържание на курса
Урок 8 — Сървърът
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 2 × 45 мин
Какво изграждаш: HTTP API на revisor
Какво научаваш: HTTP сървър, типизирани маршрути, JSON, конфигурация, журнал (log), коректно спиране
След урока ще можеш да
- Създадеш HTTP сървър на Node, който слуша за връзки, отговаря на заявка и се затваря, без да оставя отворени ресурси.
- Моделираш известните маршрути като обединение на TypeScript и отговаряш изрично на пътищата, които не съществуват, и на методите, които не допускаш.
- Изпращаш JSON отговори с правилния код на състоянието и заглавна част
content-typeи публикуваш договор, който не изтича вътрешни данни. - Четеш променливата на средата
PUERTOкато ненадежден текст и я валидираш, преди да я предадеш на сървъра. - Записваш оперативни събития, без да смесваш журнала (log) с бизнес правилата или с отговорите към клиента.
- Затваряш сървъра коректно (graceful shutdown), когато получи
SIGTERMилиSIGINT, като изчакваш заявките, които вече са били в ход. - Сглобиш
revisorот предишните уроци в процес, който проверява истински цели и отговаря наGET /api/estados.
Защо, преди как
До предишния урок revisor вече върши полезна работа. Има модел Servicio, представя всяка развръзка с дискриминирано обединение Estado, проверява няколко цели едновременно с revisarTodos, валидира конфигурацията си, преди да я използва, и е организиран в модули с тестове. Но всичко това живее в процес, който ти стартираш и четеш от терминала. Полезно е за разработка и има важно ограничение: всяка друга програма, която иска да узнае отчета, би трябвало сама да изпълни revisor, да тълкува текст, предназначен за хора, или да импортира вътрешни модули на чужд проект.
HTTP API променя тази граница. Вместо да иска от всеки потребител да умее да чете файлове, да пуска заявки и да подрежда резултати, процесът на revisor запазва тази отговорност и предлага публична операция: „дай ми текущото състояние“. API (програмен интерфейс на приложение) е именно това: съвкупност от операции, които една програма предлага на други програми, с договор, който казва какво може да се поиска и какво се получава в замяна. Уеб табло, като това от урок 9, ще може да поиска тази информация от браузър. Би могло да го направи и известие, интеграция за внедряване или инструмент за поддръжка. API не заменя логиката, която вече построи: поставя я зад врата с видим договор.
HTTP е простичък разговор между двама участници. Клиентът изпраща заявка с метод (GET, POST…), път, заглавни части и понякога тяло. Сървърът решава как да я обслужи и връща отговор с код на състоянието, заглавни части и тяло. В най-малкия случай клиентът иска GET /salud, сървърът отговаря 200 и тяло ok. В случая на revisor клиентът ще поиска GET /api/estados и ще получи JSON с резултата от проверката на всички услуги в този момент.
Думата „сървър“ може да изглежда по-голяма, отколкото е. Не ти трябва акаунт, външна услуга или допълнителна библиотека, за да започнеш: Node включва модула node:http, който приема TCP връзки, превръща ги в обекти на заявка и отговор и изпълнява функция за всяка заявка. Уеб рамка (framework) може да спести код, когато проектът има много маршрути, валидатори и middleware (междинни функции, които обработват заявката преди или след крайния обработчик), но е добре първо да разбереш основния договор, който тази рамка управлява. Ако не знаеш кога се пише заглавна част, какво става с непознат маршрут или как се затваря процесът, смяната на синтаксиса не премахва проблема; само го скрива.
Добре е да различаваш и две посоки на комуникация. Урок 5 остави подготвен типа Consultar, който описва как revisor пита за чужда услуга; в този урок ще напишеш първата истинска реализация на този тип с fetch. А revisor ще бъде и HTTP сървър за собствените си потребители. И двете роли използват HTTP кодове, URL и тела на отговори, но отговорностите им са противоположни. Като клиент revisor превежда отдалечени отговори и мрежови откази в Estado. Като сървър превежда вътрешните си Estado в стабилен отговор, който други хора и програми могат да консумират, без да знаят вътрешностите му.
Типът на TypeScript помага особено на този слой, защото API събира няколко малки решения, които в JavaScript обикновено остават неизказани. Какви маршрути съществуват? Каква форма има отговорът на всеки? Каква конфигурация е валидна, за да се стартира? Какви събития се записват? Какво се случва, когато се получи сигнал за спиране? Типът не спира връзка, нито сам по себе си пази порта на процеса, но прави видими договорите, които трябва да поддържаш, докато програмата расте.
Сървърът не бива да се превръща и във второ приложение, което дублира всичко. Валидацията на файлове продължава да принадлежи на configuracion.ts. Едновременната проверка продължава да принадлежи на revisar.ts. Представянето за хора продължава да принадлежи на reporte.ts. Сървърът е външен слой: тълкува заявка, извиква функциите на домейна и приспособява резултата към HTTP. Това разделение позволява една и съща проверка да захранва API, конзолата и таблото, без всеки потребител да преоткрива правилата за наличност.
Go предлага полезно сравнение. С net/http Go също позволява да регистрираш функция, която обслужва заявки, и да стартираш сървър от стандартната библиотека. Node следва подобна идея: процес слуша, функция получава заявка и отговор, а програмата решава маршрути, кодове и спиране. Разликата е в начина, по който се изразява чакането. В Go е обичайно функция за затваряне да връща error; в Node много мрежови операции се изразяват със събития или callback (функции за обратно извикване), които ти обвиваш в обещание, за да можеш да използваш await, както ще направиш в този урок.
Преди да пишеш маршрути, възприеми една оперативна идея: сървърът не е функция, която „свършва и това е“. Живее, докато слуша връзки, затова границите му са по-важни, отколкото при кратка програма. Трябва да има валидирана конфигурация, преди да отвори порта, да записва събитията, които помагат да се диагностицира, и да се затваря нарочно, когато системата трябва да го спре. Ако тези решения се оставят за накрая, се появяват като процеси, които не завършват, заети портове или записи, които не обясняват защо една заявка се е провалила.
Този урок запазва структурата, която фиксира в уроци 1 и 7: единственият вход е src/main.ts, който tsc компилира до dist/main.js; rootDir е ./src, а outDir е ./dist; а скриптовете се казват compilar, verificar, arrancar, probar, lint и formato. Не се инсталира нова зависимост: node:http и fetch идват с Node. Променят се файловете в src/: добавят се contrato.ts, consulta.ts, archivo.ts, bitacora.ts и servidor.ts и се пренаписва main.ts, така че вместо да отпечатва примерен отчет, да стартира сървър.
Понятията
HTTP сървър: да слушаш не е да отговаряш
createServer изгражда обект сървър. Този обект още не заема никакъв порт и не получава трафик. За да започне да слуша, трябва да извикаш listen. Всеки път, когато пристигне заявка, Node ще извика функцията, която си предал на createServer, с два обекта: IncomingMessage, който представя заявката, и ServerResponse, който представя отговора, който ще построиш.
Разделението е важно, защото създаването, слушането и отговарянето са различни фази. Можеш да построиш сървъра, без да го стартираш, за да тестваш обработчика му. Можеш да избереш порт в конфигурацията, преди да го отвориш. И можеш да спреш сървъра, след като си го използвал. Ако събереш всичко в едно дълго извикване без имена, по-трудно се вижда коя операция се е провалила: дали не е могла да се прочете конфигурацията, дали портът е бил зает, или маршрутът е отговорил зле.
Минималният HTTP отговор има две относими части. Кодът на състоянието съобщава общия резултат: 200 означава успех, 404 означава, че поисканият ресурс не съществува, а 500 представя отказ на сървъра. Тялото съдържа подробността, която клиентът може да прочете. Заглавните части показват как да се тълкува това тяло; за текст text/plain; charset=utf-8 декларира както типа на съдържанието, така и кодирането на знаците.
Следващата програма създава маршрут за здраве. Използва порт 0, който моли операционната система да избере свободен. Така не зависиш от това порт 3000, 8080 или друг фиксиран порт да е свободен на твоята машина. Програмата получава избрания порт само за да може собственият ѝ fetch да направи заявка; не го отпечатва, защото този избор варира между изпълненията. Тъй като използва await на най-високото ниво, изпълни я в папката figuras/, която подготви в урок 1 и чийто package.json декларира "type": "module".
// fig08_01.ts
import { createServer } from "node:http";
function cerrar(servidor: ReturnType<typeof createServer>): Promise<void> {
return new Promise((resolve, reject) => {
servidor.close((error) => {
if (error === undefined) {
resolve();
} else {
reject(error);
}
});
});
}
const servidor = createServer((solicitud, respuesta) => {
if (solicitud.url === "/salud") {
respuesta.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
});
respuesta.end("ok");
return;
}
respuesta.writeHead(404, {
"content-type": "text/plain; charset=utf-8",
});
respuesta.end("no encontrado");
});
await new Promise<void>((resolve) => {
servidor.listen(0, "127.0.0.1", resolve);
});
const direccion = servidor.address();
if (direccion === null || typeof direccion === "string") {
throw new Error("el servidor no entregó una dirección TCP");
}
const puerto = direccion.port;
const respuesta = await fetch(`http://127.0.0.1:${puerto}/salud`);
console.log(`${respuesta.status} ${await respuesta.text()}`);
await cerrar(servidor);
$ npx tsc --strict --target ES2022 --module nodenext --types node fig08_01.ts
$ node fig08_01.js
200 ok
Префиксът node: идентифицира собствените модули на Node и не позволява да се объркат с пакет, инсталиран от проекта. Тъй като програмата импортира модул на Node, командата включва --types node: TypeScript се нуждае от тези декларации, за да познава createServer и свойствата на заявките, а Node предоставя истинското поведение, когато се изпълнява JavaScript.
respuesta.end(...) е решаващо. Записва крайното тяло и сигнализира, че отговорът е свършил. Ако забравиш да го завършиш, клиентът може да остане да чака, макар сървърът вече да е изчислил съдържанието му. Добра практика е и да използваш return след отговор, който затваря клон: не е необходимо, за да работи HTTP, но не позволява следващият код да се опита да запише втори отговор върху същата връзка.
Забележи и как се получава портът. servidor.address() връща string | AddressInfo | null: низ, ако сървърът слуша на сокет на Unix, обект с порта, ако слуша на TCP, и null, ако още не слуша. Фигурата отхвърля двата случая, които не ѝ трябват, с изрична проверка и хвърля грешка, ако се случат. Тази проверка върши работата, която твърдение за тип само би имитирало: след нея TypeScript знае, че direccion е AddressInfo и че direccion.port съществува, без да му обещаваш нищо.
Вътре в revisor /salud не се нуждае да проверява всички услуги, нито да чете целия отчет. Въпросът му е по-малък: „HTTP процесът жив ли е и може ли да отговори?“. Тази разлика е полезна в експлоатацията. Ако /salud не отговаря, проблемът може да е в процеса, в порта или в локалната мрежа. Ако /salud отговаря, а /api/estados съобщава откази, процесът работи, а проблемът е в проверяваните услуги или в заявката към тях. Не обявявай цялата платформа за налична само защото сървърът отговаря 200: маршрутът за здраве проверява живота на процеса, а отчетът за състоянията представя резултата от външни цели. Това са различни въпроси и трябва да запазят различни имена и отговори.
Типизирани маршрути: външният URL не е надеждно обединение
Маршрут, получен по HTTP, пристига като текст. Всеки клиент може да поиска /api/estados, /api/estado, /API/ESTADOS, /borrar-todo или маршрут с неочаквани параметри. Типът на solicitud.url отразява тази действителност: той е string | undefined. Не можеш да декларираш, че този външен вход вече е един от твоите маршрути само защото би ти се искало да е така.
Правилната операция има две стъпки. Първо анализираш външния текст и го превръщаш във вътрешно представяне. После останалата част от обработчика работи с ограничено обединение. Това е същият граничен шаблон от урок 6: отвън пристига широка стойност; след валидиране и класифициране домейнът получава известни алтернативи.
Обединението Ruta не променя какво може да напише човек в адресната лента на браузъра. Но не позволява на останалата част от програмата да третира непознат маршрут като валиден. Ако добавиш бъдещ маршрут, TypeScript може да ти помогне да намериш местата, където трябва да решиш кода на състоянието, тялото и формата на отговора; в крайния проект това ще стане със защитата за изчерпателност с never, която видя в урок 3. В тази фигура състоянията са написани на ръка във файла, за да е програмата изпълнима сама по себе си; в проекта ще идват от revisarTodos.
// fig08_02.ts
import { createServer } from "node:http";
interface Servicio {
readonly nombre: string;
readonly url: string;
readonly timeoutMs: number;
}
type Estado =
| {
readonly servicio: Servicio;
readonly tipo: "disponible";
readonly codigoHttp: number;
readonly duracionMs: number;
}
| {
readonly servicio: Servicio;
readonly tipo: "falla";
readonly detalle: string;
};
type Ruta =
| { readonly tipo: "salud" }
| { readonly tipo: "estados" }
| { readonly tipo: "no-encontrada" };
function reconocerRuta(url: string | undefined): Ruta {
const ruta = new URL(url ?? "/", "http://revisor.local").pathname;
if (ruta === "/salud") {
return { tipo: "salud" };
}
if (ruta === "/api/estados") {
return { tipo: "estados" };
}
return { tipo: "no-encontrada" };
}
function cerrar(servidor: ReturnType<typeof createServer>): Promise<void> {
return new Promise((resolve, reject) => {
servidor.close((error) => (error === undefined ? resolve() : reject(error)));
});
}
const estados: readonly Estado[] = [
{
servicio: {
nombre: "catálogo",
url: "https://catalogo.example",
timeoutMs: 1500,
},
tipo: "disponible",
codigoHttp: 200,
duracionMs: 42,
},
{
servicio: {
nombre: "pagos",
url: "https://pagos.example",
timeoutMs: 3000,
},
tipo: "falla",
detalle: "tiempo límite",
},
];
const servidor = createServer((solicitud, respuesta) => {
const ruta = reconocerRuta(solicitud.url);
if (ruta.tipo === "salud") {
respuesta.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
respuesta.end("ok");
return;
}
if (ruta.tipo === "estados") {
respuesta.writeHead(200, { "content-type": "application/json; charset=utf-8" });
respuesta.end(JSON.stringify({ estados }));
return;
}
respuesta.writeHead(404, { "content-type": "application/json; charset=utf-8" });
respuesta.end(JSON.stringify({ detalle: "ruta no encontrada" }));
});
await new Promise<void>((resolve) => {
servidor.listen(0, "127.0.0.1", resolve);
});
const direccion = servidor.address();
if (direccion === null || typeof direccion === "string") {
throw new Error("el servidor no entregó una dirección TCP");
}
const puerto = direccion.port;
const estadosRespuesta = await fetch(`http://127.0.0.1:${puerto}/api/estados`);
const desconocidaRespuesta = await fetch(`http://127.0.0.1:${puerto}/api/no-existe`);
console.log(await estadosRespuesta.text());
console.log(`${desconocidaRespuesta.status} ${await desconocidaRespuesta.text()}`);
await cerrar(servidor);
$ npx tsc --strict --target ES2022 --module nodenext --types node fig08_02.ts
$ node fig08_02.js
{"estados":[{"servicio":{"nombre":"catálogo","url":"https://catalogo.example","timeoutMs":1500},"tipo":"disponible","codigoHttp":200,"duracionMs":42},{"servicio":{"nombre":"pagos","url":"https://pagos.example","timeoutMs":3000},"tipo":"falla","detalle":"tiempo límite"}]}
404 {"detalle":"ruta no encontrada"}
new URL отделя пътя от други компоненти на един URL, като заявката (query) и фрагмента. Така /api/estados?orden=nombre продължава да разпознава същия базов маршрут, дори ако по-нататък решиш да тълкуваш параметъра orden. Не сравнявай текст с пълен URL, ако те интересува само pathname: заявка, добавена от клиент, би променила текста, макар ресурсът да е същият. Вторият аргумент на new URL е фиктивна база, http://revisor.local, която съществува само за да приема конструкторът относителни пътища като /salud; никога не се използва, за да се свърже с нещо.
Забележи, че Ruta използва дискриминирано обединение. Полето tipo изпълнява същата функция като в Estado: позволява на TypeScript да стеснява типа във всеки клон. Маршрутът за здраве не се нуждае от състоянията. Ненамереният маршрут не бива да връща по погрешка вътрешната колекция. С добавянето на маршрути тази структура държи заедно решението за разпознаване и решението за отговор.
404 не е изключение, нито незадължителен текст. Това е правилният отговор, когато процесът съществува, но не предлага поисканият ресурс. Да отговориш 200 с фраза, която казва „не е намерено“, принуждава всеки клиент да измисля правила за тълкуване на тялото. HTTP кодовете вече съобщават тази категория резултат; използвай ги, за да споделят браузърите, инструментите и таблото един и същ език.
Предишната фигура съдържа и нарочен дефект и е добре да го видиш сега. JSON ѝ включва за всяко състояние цялата servicio: нейния url и timeoutMs. Това е удобно за програмиста, защото е точно обектът, който вече съществува в паметта, и е грешка за API: току-що си публикувал адреса на вътрешните си услуги, а всяка промяна в модела ще променя отговора, без никой да го е решавал. Следващият раздел поправя това.
JSON и публичен договор: това, което излиза, не е това, което има вътре
JSON е формат за данни, а не доказателство, че данните са правилни. JSON.stringify превръща обекти на revisor в текст за HTTP отговор. От другата страна respuesta.json() превръща JSON текст в стойност, която клиентът трябва да третира като външна, докато не я валидира. Разликата прилича на тази от урок 6: сървърът познава своите Estado; таблото от следващия урок ще получава JSON и ще трябва да реши дали отговорът изпълнява договора, който очаква.
Заглавната част content-type: application/json; charset=utf-8 е част от това споразумение. Много клиенти могат да отгатнат, че тялото е JSON по първия му знак, но не би трябвало да се налага да го правят. Заглавната част декларира какъв формат се изпраща и позволява на HTTP инструментите, браузърите и библиотеките да го третират правилно. Не изпращай JSON с text/plain само защото се вижда добре в терминал.
Още по-важна е формата. Вътрешният модел Estado пази цялата Servicio, защото revisarTodos трябва да свърже всеки резултат с конфигурацията му. Публичният договор е друго нещо: това е, което чужд човек или програма трябва да знае, и нищо повече. Затова проектът създава src/contrato.ts с два типа, EstadoPublico и ReportePublico. EstadoPublico също е дискриминирано обединение по tipo, но вместо servicio: Servicio носи само nombre. Няма url, няма timeoutMs. А една функция, aReportePublico, в reporte.ts, изгражда всеки публичен обект поле по поле. Да го построиш така, вместо да копираш Estado и да му махаш свойства, има предимство, което се оценява с времето: ако утре Servicio добави токен, акаунт или политика за повторни опити, API не го публикува по погрешка, защото отговорът съдържа само това, което някой е написал там нарочно.
Същият файл носи и трета част, esReportePublico(valor: unknown): valor is ReportePublico, която проверява при изпълнение, че неизвестна стойност има тази форма. Изглежда излишна в сървъра, който е този, който произвежда JSON. Не е излишна по две причини. Първо, тестовете на този урок я използват, за да четат отговора на API, без да приемат на сляпо това, което връща respuesta.json(). Второ, таблото от урок 9 консумира точно този договор от браузъра и там вече е мрежова граница: файлът contrato.ts не импортира нищо от Node, така че може да пътува непроменен до браузъра заедно с таблото. Това е първият тип, споделен между сървъра и екрана, и споделя двете неща, които трябва да пътуват заедно: формата и начина да се провери.
Избягва и да се публикуват подробности, които не са част от договора. JSON на /api/estados може да включва nombre, tipo, код, продължителност или подробност, защото са полезни данни на отчета. Не бива да връща променливи на средата, пътища до локални файлове, заглавни части на заявките или груби технически съобщения по удобство. Публичното API пази минимални, нарочни и документирани данни. Това правило се разпростира и върху detalle на един отказ: този текст стига до клиента, затова проектът го изгражда с кратък и контролиран речник („tiempo límite agotado“, „conexión rechazada“, „HTTP 503“) вместо да препраща оригиналното съобщение на мрежово изключение, което би могло да разкрие адреси или пътища.
Конфигурация: портът е текст, а не число
Конфигурацията следва същия граничен принцип. process.env.PUERTO идва от средата и не е сигурно число: Node винаги предава низ, макар този, който внедрява, да е написал PUERTO=8080. Може да липсва, да има интервали, да съдържа ochenta, да е 0, да е десетично или да надвишава валидния диапазон на портовете. Да го преобразуваш с Number(...) без да провериш резултата премества проблема до listen, където съобщението зависи от операционната система и е по-малко ясно за този, който е конфигурирал процеса.
Урок 6 вече ти даде инструмента: leerEnteroPositivo(nombre, valor, predeterminado), който проверява формата с регулярен израз, преди да преобразува, използва стойността по подразбиране само когато променливата липсва и отхвърля присъстваща, но невалидна стойност. Тук се добавя към configuracion.ts както е и върху нея се изгражда leerPuerto, който прибавя правилото, собствено за портовете: не могат да надвишават 65535. Следващата фигура събира двете функции и ги тества с пет входа, включително липсата на променливата.
// fig08_03.ts
type Resultado<T> = { ok: true; valor: T } | { ok: false; detalle: string };
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 };
}
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;
}
const entradas: readonly (string | undefined)[] = [undefined, "8080", "65536", "0", "hola"];
for (const entrada of entradas) {
const resultado = leerPuerto(entrada);
const texto = resultado.ok ? `puerto ${resultado.valor}` : `rechazado: ${resultado.detalle}`;
console.log(`PUERTO=${entrada} -> ${texto}`);
}
$ npx tsc --strict --target ES2022 --module nodenext --types node fig08_03.ts
$ node fig08_03.js
PUERTO=undefined -> puerto 3000
PUERTO=8080 -> puerto 8080
PUERTO=65536 -> rechazado: PUERTO debe estar entre 1 y 65535
PUERTO=0 -> rechazado: PUERTO debe ser un entero positivo
PUERTO=hola -> rechazado: PUERTO debe ser un entero positivo
Портът 0, който в fig08_01 беше полезен, защото молеше операционната система за свободен порт, тук се отхвърля: той е инструмент за тестове, а не конфигурация, защото публикувана услуга се нуждае от предвидим порт, на който клиентите ѝ да я намират. Стойността по подразбиране 3000 е изрично решение на проекта, а не специално свойство на Node. Валидно е да избереш друга стойност или да изискаш PUERTO да съществува, стига програмата да го съобщава и тества. Важното е да не позволяваш липсваща стойност да се превърне по погрешка в непознато поведение. Регулярният израз ^[1-9]\d*$ отхвърля интервали, знаци, водещи нули и десетични числа, преди да преобразува; после Number.isSafeInteger потвърждава, че преобразуването е произвело цяло число, представимо по безопасен начин, а диапазонът на портовете допълва договора. Да валидираш на слоеве може да изглежда повтарящо се в сравнение с простия parseInt, но не позволява да се приемат двусмислени случаи като 3000texto, който parseInt би преобразувал частично в 3000.
Вътре в revisor конфигурацията на сървъра не се смесва с конфигурацията на услугите. Валидираният списък от Servicio отговаря какви цели се проверяват и с какъв timeoutMs; живее във файл, servicios.json, и се чете с leerServicios от урок 6. Портът отговаря къде слуша API; живее в променлива на средата. Да държиш двете концепции разделени позволява да смениш порта, без да пипаш договора на всяка цел, и позволява да преизползваш логиката на проверката от тест, без да отваряш TCP връзка. А правило, което вече познаваш от урок 6, продължава да важи: ако нещо липсва или е невалидно, програмата съобщава коя променлива се е провалила, без да отпечатва останалата част от средата, която би могла да съдържа тайни.
Журнал (log) и коректно спиране: експлоатацията също е част от програмата
Журналът записва факти, които помагат да се отговори на оперативни въпроси: процесът стартира ли? Каква заявка е пристигнала? Какъв код е бил върнат и колко е отнело? Кога е започнало затварянето? Приключило ли е? Не заменя HTTP отговора. Отговорът е за клиента, който е направил заявката; журналът е за този, който експлоатира системата и диагностицира проблеми по-късно, и затова никога не бива да се смесва с това, което се отговаря на клиента.
Съобщенията трябва да имат структура и цел. Текст като algo pasó не позволява да се филтрират или сравняват събития. Запис с evento и detalle, написан като ред JSON, пази стабилна категория и човешко описание, а всеки инструмент за анализ на журнали знае да го чете. В по-голяма услуга би добавил ниво, идентификатор на заявката и още полета. И не използвай журнала, за да копираш тайни, цели тела на заявки или токени за авторизация: файл с журнал обикновено циркулира повече, отколкото си представяш. По тази причина проектът записва pathname на всяка заявка, а не пълния URL, защото заявката (?token=…) е точно мястото, където хората, без да мислят, слагат това, което не бива да остава записано.
Затварянето заслужава същото внимание като стартирането. Извикването на servidor.close(...) спира приемането на нови връзки и съобщава чрез своя callback, когато сървърът е завършил затварянето, тоест когато не е останала нито една активна връзка. Не значи, че заявка в ход изчезва в този миг: коректното спиране позволява да се довърши това, което вече е било в ход. Ако процесът излезе, без да изчака това известие, можеш да прекъснеш отговор по средата или да загубиш последния запис.
В продукция този, който спира процеса ти, почти никога не е човек, който пише команда: това е надзорник (systemd, оркестратор на контейнери, натискането на Ctrl+C в терминала ти), който изпраща сигнал на процеса. SIGTERM означава „приключи, когато можеш“, а SIGINT е това, което изпраща Ctrl+C. Ако не слушаш сигнала, Node приключва веднага, без да затвори нищо. Ако го слушаш, ти решаваш какво да направиш, преди да излезеш.
Следващата фигура демонстрира цялата последователност със случай, който я прави видима. Сървърът отговаря бавно, след 100 ms. Програмата пуска заявка, чака сървърът да я получи и тогава сама си изпраща SIGTERM с process.kill(process.pid, "SIGTERM"). Обърни внимание на реда на записите: затварянето започва, докато заявката още е в ход, клиентът въпреки това получава пълния си отговор и едва след това се записва cerrado.
// fig08_04.ts
import { createServer, type Server } from "node:http";
function registrar(evento: string, detalle: string): void {
console.log(JSON.stringify({ evento, detalle }));
}
function cerrar(servidor: Server): Promise<void> {
return new Promise((resolve, reject) => {
servidor.close((error) => {
if (error === undefined) {
resolve();
} else {
reject(error);
}
});
});
}
let llegoLaSolicitud: () => void = () => {};
const solicitudRecibida = new Promise<void>((resolve) => {
llegoLaSolicitud = resolve;
});
const servidor = createServer((_solicitud, respuesta) => {
registrar("solicitud", "llegó; responderá en 100 ms");
llegoLaSolicitud();
setTimeout(() => {
respuesta.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
respuesta.end("terminé");
}, 100);
});
let cierre: Promise<void> | undefined;
function detener(senal: string): void {
cierre ??= (async () => {
registrar("cierre", `${senal} recibida: no se aceptan conexiones nuevas`);
await cerrar(servidor);
registrar("cerrado", "ya no queda ninguna solicitud en curso");
})();
}
process.once("SIGTERM", () => detener("SIGTERM"));
await new Promise<void>((resolve) => {
servidor.listen(0, "127.0.0.1", resolve);
});
const direccion = servidor.address();
if (direccion === null || typeof direccion === "string") {
throw new Error("el servidor no escucha en un puerto TCP");
}
const pendiente = fetch(`http://127.0.0.1:${direccion.port}/lento`).then((respuesta) =>
respuesta.text(),
);
await solicitudRecibida;
process.kill(process.pid, "SIGTERM");
registrar("cliente", `recibió «${await pendiente}»`);
await cierre;
$ npx tsc --strict --target ES2022 --module nodenext --types node fig08_04.ts
$ node fig08_04.js
{"evento":"solicitud","detalle":"llegó; responderá en 100 ms"}
{"evento":"cierre","detalle":"SIGTERM recibida: no se aceptan conexiones nuevas"}
{"evento":"cliente","detalle":"recibió «terminé»"}
{"evento":"cerrado","detalle":"ya no queda ninguna solicitud en curso"}
Редът на записите показва важно свойство: cerrado се записва едва след await cerrar(servidor). Да го запишеш преди това би било невярно твърдение: процесът би могъл още да обслужва активна връзка или дори да се провали при затварянето. Малкото обещание cerrar адаптира API, основано на callback, на server.close към асинхронната форма, която вече познаваш от урок 5.
Променливата cierre заслужава внимание. Два различни сигнала могат да пристигнат с малка разлика, например надзорник, който изпраща SIGTERM, докато някой натиска Ctrl+C (SIGINT), и всеки би се опитал да затвори същия сървър. Операторът ??= присвоява обещанието за затваряне само ако още не съществува, така че вторият сигнал преизползва затварянето, което е в ход, вместо да започне друго. Освен това обработчикът не извиква process.exit(): този метод прекратява процеса мигновено и би прекъснал точно това, което току-що показа, че трябва да се изчака. Когато не остане висяща работа, Node приключва сам с кода на изход, който съответства.
Бележка за process.once: регистрира обработчика за един-единствен сигнал от всеки вид. Ако втори SIGINT (втори Ctrl+C) пристигне, след като първият обработчик вече се е изпълнил, Node се връща към поведението си по подразбиране и прекратява процеса веднага. Това е разумен авариен изход за този, който натисне Ctrl+C два пъти, защото коректното спиране отнема твърде дълго, и означава също, че при два сигнала от един и същ вид защитата на ??= не стига да се упражни. Ако предпочиташ друга политика, например да изчакаш максимум секунди и после да принудиш излизането, това е решение на проекта, а не свойство на Node.
Вътре в revisor записвай събития от границата, а не всяка вътрешна подробност на една чиста функция. revisarTodos може да връща състояния, без да знае дали ще се отпечатат, ще се изложат по HTTP или ще се покажат на екран. servidor.ts знае, че е обслужил GET /api/estados, какъв код е върнал и колко е отнело. Това е правилният слой за журнала на заявките. А журналът не е и извинение да прихващаш всяко изключение и да продължаваш, сякаш нищо не е станало: ако не можеш да отвориш порта, защото е зает, запиши проблема с контекст и остави стартирането да се провали с код на изход, различен от нула.
Сглобеният revisor: API извиква истинската проверка
Сега събираш частите, без да правиш сървъра собственик на проверката. Моделът и revisarTodos запазват договора от предишните уроци, без нито един променен ред. servidor.ts получава функция, която взема отчета, и друга, която записва събития, и не знае откъде идват. main.ts е единствената част, която познава всички: чете порта и файла с услугите, сглобява функцията, която наистина проверява, отваря порта и регистрира обработчиците на сигнали. Тази фигура се променя спрямо предишните изолирани примери: състоянията вече не са фиксиран списък; произвеждат се при извикването на revisarTodos при всяка заявка.
Истинска цел: consulta.ts. Това е първата истинска реализация на типа Consultar от урок 5. Получава Servicio и AbortSignal, иска URL на услугата с fetch, измерва колко е отнело с performance.now() и връща HTTP кода и продължителността. Две подробности са важни. Първо, сигналът, който получава, е този, който revisarTodos е създал с AbortSignal.timeout(servicio.timeoutMs): ако услугата не отговори навреме, fetch се прекъсва сам, без consulta.ts да планира таймер. Второ, след като прочете кода на състоянието, функцията отменя тялото на отговора с respuesta.body?.cancel(): на revisor му е важно услугата да отговори, а не да изтегля съдържанието ѝ, а да оставиш тялото непрочетено държи връзката заета.
Това, което се променя спрямо наивен fetch, е как се превеждат отказите. Когато fetch не успее да се свърже, хвърля TypeError със съобщение „fetch failed“, което само по себе си не казва нищо полезно; истинската причина пътува в error.cause и е друг Error със свойство code, като ECONNREFUSED. Помощната функция codigoDeRed обхожда тази верига с проверките, които вече познаваш (instanceof Error, "code" in ..., typeof ... === "string") и връща кода или undefined, без нито едно твърдение. С нея consultarConFetch хвърля едно от три кратки съобщения: „tiempo límite agotado“, ако сигналът е прекъснат, „conexión rechazada“, ако кодът е бил ECONNREFUSED, и „no se pudo conectar“ във всеки друг случай. Всеки throw носи { cause: error }, което прикрепя оригиналната грешка към новата: ESLint, с препоръчаната си конфигурация, изисква точно това (правило preserve-caught-error) и е прав, защото така този, който дебъгва, пази истинската причина на една стъпка, макар клиентът на API да вижда само краткото съобщение.
Файлът с услугите: archivo.ts. Това е границата с диска, както я начерта урок 7: чете servicios.json, превръща текста в unknown с JSON.parse и предава тази стойност на leerServicios, която вече съществуваше. Връща Resultado, така че липсващ файл, неправилно оформен JSON и невалидна услуга завършват на едно и също място: четима подробност, а не изключение с трасе, което някой трябва да разшифрова. Забележи, че JSON.parse връща any и че се присвоява на променлива, декларирана unknown: това не изисква никакво твърдение и принуждава leerServicios да провери това, което получава.
Сървърът: servidor.ts. Формата му е като на предишните фигури, заздравена. crearServidor(opciones) връща Server, без да го пуска да слуша. atender първо отговаря за методите: ако не е GET, отговаря 405 със заглавна част allow: GET, което е стандартният начин да се каже на клиента какво може да прави. След това разпознава маршрута с обединение и switch, чийто default използва защитата never от урок 3. В /api/estados първо чака obtenerReporte() и едва след това записва отговора: ако го направеше обратното, отказ по средата би оставил вече изпратен 200 с повредено тяло. Ако отчетът се провали, записва техническата причина в журнала и отговаря 500 с {"detalle":"error interno"}: клиентът получава нещо стабилно и безопасно, а този, който експлоатира, има причината. escuchar обвива listen в обещание, което се отхвърля, ако сървърът излъчи error (например EADDRINUSE, зает порт); без това грешката би се излъчила като събитие без обработчик и би съборила процеса с трасе. puertoDe капсулира проверката на address(), която видя във fig08_01.
Има един ред, който заслужава обяснение: void atender(...) вътре в обработчика на createServer. atender е функция async и затова връща обещание; обработчикът на Node не го изчаква. Операторът void декларира, че игнорирането на това обещание е умишлено. Безопасно е, защото crearServidor верижно прикрепя .catch(...) към това обещание: ако atender се провали по причина, която не е предвидила, грешката се записва и клиентът получава общ 500. Без този .catch изключение вътре в atender би било отхвърлено обещание без обработка и Node би прекратил целия процес: един-единствен клиент със странна заявка би съборил услугата за всички. Целта на една заявка я пише клиентът и не всяка цел може да се анализира: curl --request-target "//" изпраща такава, за която new URL хвърля TypeError: Invalid URL. Затова rutaDe прихваща тази грешка и връща "?", маркер, който никой истински маршрут не може да има (всеки анализиран маршрут започва с /): пада в 404 и журналът показва, че е пристигнало нещо нечетимо, вместо празен маршрут. Един тест изпраща точно такава заявка.
Входната точка: main.ts. Тя е композиция, а не място за правила. Чете и валидира порта; чете и валидира услугите; сглобява сървъра с функция obtenerReporte, която извиква revisarTodos с consultarConFetch и превръща резултата с aReportePublico; отваря порта с escuchar; записва escuchando; и свързва SIGTERM и SIGINT към коректно спиране със същото споделено обещание от fig08_04. Ако нещо от това се провали, преди сървърът да отвори порта, записва събитието, задава process.exitCode = 1 и се връща: процесът приключва сам с код за грешка, без да извиква process.exit(). Обърни внимание на реда: първо се валидира и после се отваря портът, никога обратното.
Тестовете спират да са играчка. configuracion.test.ts използва таблица от случаи, както в урок 7, за leerPuerto. contrato.test.ts използва друга за esReportePublico: един валиден отчет и четири начина да се сгреши (null, estados, което не е масив, неизвестен tipo и codigoHttp, който пристига като текст). reporte.test.ts получава тест, че публичният отчет не съдържа URL, нито timeoutMs. А servidor.test.ts прави това, което дава най-голямо доверие, освен да тества маршрути, методи, 500 без да изтича вътрешната подробност и заявката с цел //, която трябва да получи 404, без да събори процеса: вдига истински сървър-цел с четири поведения (/ok отговаря 200, /caido отговаря 503, затворен порт отказва връзката и /lento никога не отговаря), вдига сървъра на revisor с истинския consultarConFetch, прави истински fetch към /api/estados и проверява всяка развръзка: налична, отказ по HTTP 503, отказ по отказана връзка и отказ по таймаут, последната с timeoutMs от 150 ms. После проверява това, което не бива да се появява: нито един url в JSON. Всеки тест затваря сървърите си в блок finally, за да не остави провал на твърдение отворен порт и увиснал процес на тестовете.
Новите или променените файлове са тези. Тези, които не се променят спрямо урок 7, се появяват в края на раздела, пълни, за да е проектът възпроизводим от начало до край.
// fig08_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);
}
// fig08_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 }));
};
// fig08_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 });
}
};
// fig08_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);
}
// fig08_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";
export type ObtenerReporte = () => Promise<ReportePublico>;
export interface OpcionesServidor {
readonly obtenerReporte: ObtenerReporte;
readonly registrar: Bitacora;
}
type Ruta =
{ readonly tipo: "salud" } | { readonly tipo: "estados" } | { readonly tipo: "no-encontrada" };
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 "/salud":
return { tipo: "salud" };
case "/api/estados":
return { tipo: "estados" };
default:
return { tipo: "no-encontrada" };
}
}
function enviarJson(respuesta: ServerResponse, codigo: number, cuerpo: unknown): void {
respuesta.writeHead(codigo, { "content-type": "application/json; charset=utf-8" });
respuesta.end(JSON.stringify(cuerpo));
}
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 "salud":
respuesta.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
respuesta.end("ok");
return;
case "estados":
try {
enviarJson(respuesta, 200, await opciones.obtenerReporte());
} catch (error: unknown) {
opciones.registrar({
evento: "error",
detalle: error instanceof Error ? error.message : "falla desconocida",
});
enviarJson(respuesta, 500, { detalle: "error interno" });
}
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;
}
// fig08_05/src/main.ts
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)),
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();
configuracion.ts запазва leerServicios от урок 7 и получава три неща: esRegistro вече се експортира (използва я contrato.ts) и се добавят leerEnteroPositivo и leerPuerto. reporte.ts запазва lineaReporte и получава aReportePublico.
// fig08_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;
}
// fig08_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) };
}
Четирите теста на проекта:
// fig08_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);
});
}
// fig08_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);
});
}
// fig08_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" },
],
});
});
// fig08_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)),
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");
},
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: [] }),
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);
}
});
Файлът servicios.json описва целите, които програмата проверява, когато я стартираш на ръка. И трите съществуват: първата и втората са публични сайтове (без връзка с интернет ще видиш откази „no se pudo conectar“ за тях и това е очаквано), а третата сочи към порт на твоята собствена машина, където никой не слуша, за да видиш отказ, без да зависиш от никого.
[
{ "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 }
]
И файловете, които не се променят спрямо урок 7: конфигурацията на npm и TypeScript, ESLint и Prettier, моделът и координаторът revisarTodos.
{
"name": "revisor",
"private": true,
"type": "module",
"scripts": {
"compilar": "tsc",
"verificar": "tsc --noEmit",
"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/node": "24",
"@typescript/native": "npm:typescript@^7.0.2",
"eslint": "10.11.0",
"prettier": "3.9.9",
"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"],
"sourceMap": true
},
"include": ["src"]
}
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(js.configs.recommended, ...tseslint.configs.recommended);
{
"singleQuote": false,
"printWidth": 100
}
// fig08_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;
};
// fig08_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",
};
}
}),
);
}
$ cd fig08_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.638417ms)
✔ leerPuerto: un puerto válido (0.069958ms)
✔ leerPuerto: 65535 es el límite (0.187208ms)
✔ leerPuerto: 65536 se pasa del límite (0.0895ms)
✔ leerPuerto: 0 no es un puerto (0.048708ms)
✔ leerPuerto: un decimal se rechaza (0.033125ms)
✔ leerPuerto: texto se rechaza (0.044541ms)
✔ leerPuerto: la cadena vacía se rechaza (0.030416ms)
✔ esReportePublico: un reporte con las dos variantes (0.406375ms)
✔ esReportePublico: null (0.301167ms)
✔ esReportePublico: estados no es un arreglo (0.160583ms)
✔ esReportePublico: un estado con un tipo desconocido (0.798917ms)
✔ esReportePublico: codigoHttp llega como texto (0.062ms)
✔ disponible conserva código y duración (0.435ms)
✔ falla conserva detalle (0.072583ms)
✔ el reporte público no publica la URL ni el tiempo límite (0.337458ms)
✔ GET /api/estados revisa destinos reales y publica el reporte (165.446042ms)
✔ las rutas desconocidas, los métodos y los errores internos responden con su código (4.957833ms)
✔ una ruta que no se puede analizar responde 404 y no derriba el servidor (3.789583ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 239.299834
Този блок показва две различни граници. npm run verificar потвърждава статичните договори на всички модули; npm run probar компилира и после изпълнява истинска HTTP заявка срещу истински цели. Никой fetch в тестовете не е симулация: Node отваря локални сокети, клиентът получава отговори, таймаутът от 150 ms изтича наистина и затварянето чака сървърите да спрат да слушат.
Още не си видял програмата да работи. Компилирай я и я стартирай на порт 3100 (ако не зададеш PUERTO, ще използва 3000). Тези блокове са примерно изпълнение в твоя терминал; продължителностите и часовете ще са различни при теб.
$ npm run compilar
$ PUERTO=3100 npm run arrancar
{"momento":"2026-10-02T20:54:27.333Z","evento":"escuchando","detalle":"http://127.0.0.1:3100"}
В друг терминал:
$ curl -i http://127.0.0.1:3100/salud
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
Date: Fri, 02 Oct 2026 20:54:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
ok
$ curl http://127.0.0.1:3100/api/estados
{"estados":[{"nombre":"ejemplo","tipo":"disponible","codigoHttp":200,"duracionMs":190},{"nombre":"node","tipo":"disponible","codigoHttp":200,"duracionMs":405},{"nombre":"local-apagado","tipo":"falla","detalle":"conexión rechazada"}]}
$ curl -i http://127.0.0.1:3100/nada
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
Date: Fri, 02 Oct 2026 20:54:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"detalle":"ruta no encontrada"}
$ curl -i -X POST http://127.0.0.1:3100/api/estados
HTTP/1.1 405 Method Not Allowed
allow: GET
content-type: application/json; charset=utf-8
Date: Fri, 02 Oct 2026 20:54:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"detalle":"método no permitido"}
Върни се в първия терминал и натисни Ctrl+C. Журналът разказва цялата история, а последната двойка редове е коректното спиране:
{"momento":"2026-10-02T20:54:28.533Z","evento":"solicitud","detalle":"GET /salud 200 2 ms"}
{"momento":"2026-10-02T20:54:28.964Z","evento":"solicitud","detalle":"GET /api/estados 200 418 ms"}
{"momento":"2026-10-02T20:54:28.978Z","evento":"solicitud","detalle":"GET /nada 404 0 ms"}
{"momento":"2026-10-02T20:54:28.992Z","evento":"solicitud","detalle":"POST /api/estados 405 0 ms"}
{"momento":"2026-10-02T20:54:28.993Z","evento":"cierre","detalle":"SIGINT recibida"}
{"momento":"2026-10-02T20:54:28.994Z","evento":"cerrado","detalle":"el servidor dejó de aceptar conexiones"}
Две неща си струва да забележиш. Първото: GET /api/estados отне 418 ms, едва малко повече от най-бавната цел (405 ms) и много по-малко от сбора на трите; това е съвместното изпълнение от урок 5, което върши работата си през HTTP. Второто: всяко GET /api/estados пуска отново всички заявки. Това е най-простото решение, правилно за начало, и има цена, която ще видиш в „Какво се прави погрешно“.
Грешката, която ще видиш
Първият клас грешка се появява, когато декларираш правилно вътрешните маршрути, но извикаш функция с маршрут, който не принадлежи към обединението. С TypeScript 7.0.2 tsc съобщава TS2345 при извикването на atender.
// fig08_06.ts
type Ruta = "/salud" | "/api/estados";
function atender(ruta: Ruta): void {
console.log(ruta);
}
atender("/api/estado");
$ npx tsc --strict --target ES2022 --module nodenext fig08_06.ts
fig08_06.ts(8,9): error TS2345: Argument of type '"/api/estado"' is not assignable to parameter of type 'Ruta'.
TS2345 показва, че аргументът на извикване не изпълнява договора на параметъра. Тук не означава, че TypeScript има правописно предпочитание: разкрива висящо решение. Ако правилният публичен маршрут е /api/estados, поправи извикването. Ако наистина ти трябва маршрут в единствено число, добави го към Ruta, научи reconocerRuta как да го идентифицира и определи какъв отговор произвежда. Не решавай проблема с as Ruta; това твърдение заглушава точно проверката, която не позволява маршрути, които са декларирани, но не са реализирани.
Друга честа диагностика се появява, защото IncomingMessage.url може да е undefined. Макар нормалните HTTP заявки да имат URL, типът на Node допуска липсата му и обработчикът трябва да има изрична политика.
// fig08_07.ts
import { createServer } from "node:http";
createServer((solicitud, respuesta) => {
respuesta.end(solicitud.url.toUpperCase());
});
$ npx tsc --strict --target ES2022 --module nodenext --types node fig08_07.ts
fig08_07.ts(5,17): error TS18048: 'solicitud.url' is possibly 'undefined'.
TS18048 се появява, когато се опиташ да използваш стойност, която може да липсва. Поправката не е да напишеш solicitud.url!, защото това само обещава на компилатора, че знаеш нещо, което програмата не е проверила. Реши какво трябва да прави API при липсата. За да разпознае маршрут, solicitud.url ?? "/" предлага корен по подразбиране, което е това, което прави rutaDe в проекта. Ако URL е задължителен за конкретна операция, можеш да отговориш 400 и да завършиш заявката. Изборът зависи от договора, но трябва да съществува, преди да използваш методи на низове като toUpperCase.
Има трета грешка, която не е на компилатора, а на Node, и скоро ще я видиш: да стартираш сървъра на порт, който друг процес вече заема. Без обработка на грешки Node приключва с трасе от Error: listen EADDRINUSE. В проекта escuchar превръща това събитие в отхвърляне на обещанието, а main.ts го записва и излиза с код 1. За да го предизвикаш, стартирай две копия на един и същ порт; второто отпечатва:
$ PUERTO=3100 npm run arrancar
{"momento":"2026-10-02T20:41:59.411Z","evento":"arranque-fallido","detalle":"listen EADDRINUSE: address already in use 127.0.0.1:3100"}
EADDRINUSE означава, че портът вече има собственик: почти винаги е предишно копие на същата ти програма, което не си затворил. Преди да променяш кода, потърси кой слуша на този порт (lsof -i :3100 в Linux) или използвай друг порт с PUERTO=3101.
Какво се прави погрешно
Да отвориш сървъра, преди да валидираш конфигурацията. Ако извикаш
listenи после откриеш, чеPUERTOили списъкът от услуги е невалиден, процесът може да остане видим и недовършен. Първо валидирай външните входове; отваряй порта само когато програмата знае с какъв договор ще работи.Да използваш
solicitud.url as Ruta. Твърдението не анализира заявката и не блокира чужди маршрути. Само премахва статичната защита. Превърни външния текст чрез функция катоreconocerRutaи отговаряй404, когато няма валидна алтернатива.Да отговаряш с JSON без заглавна част
content-type. Някои клиенти ще могат да тълкуват тялото въпреки това, но други няма да имат надежден сигнал как да го прочетат. Представянето и заглавната му част образуват един-единствен HTTP договор.Да отговаряш
200при грешки в маршрута или конфигурацията. Тяло, което казва „error“ с код200, принуждава таблото и други интеграции да тълкуват фрази. Използвай HTTP кодовете за общата категория и запази тялото за подробността, от която клиентът се нуждае.Да сериализираш вътрешния модел като отговор.
JSON.stringify(estados)е един ред и работи, но публикува URL и таймаута на всяка услуга и обвързва всяка промяна на модела с промяна на API. Изгради публичния обект поле по поле.Да вярваш, че
new URLникога не се проваля. Целта на една заявка я пише клиентът, аnew URL("//", base)хвърляTypeError: Invalid URL. Изключение, което никой не прихваща в асинхронен обработчик, прекратява процеса. Прихвани грешката при анализа и отговори404или400и прикрепи.catchкъм обещанието на обработчика като последна мрежа.Да записваш отговора, преди да изчакаш резултата. Ако извикаш
writeHead(200, ...)и послеawaitработа, която може да се провали, вече не можеш да смениш кода на500:200е излязъл. Първо чакай, после отговаряй.Да вкарваш проверката на услугите в обработчика на всяка заявка без политика. Ако всяко
GET /api/estadosпуска всички отдалечени заявки, десет души, които отварят таблото, умножават трафика към твоите услуги и получават различни отчети. За начало е приемливо; в продукция реши нарочно дали API проверява при поискване, пази скорошен отчет няколко секунди или изпълнява планирани проверки.Да записваш тайни или пълния URL на всяка заявка. Журналите трябва да служат за експлоатация, а не да се превръщат в постоянно копие на чувствителни данни. Записвай метод, маршрут без заявката (query), код и продължителност; премахвай или маскирай ключове, токени и лични данни.
Да извикваш
process.exit()при получаване на сигнал. Процесът приключва веднага и може да прекъсне заявки, записи и журнали. Първо започниserver.close, изчакай края му и остави процеса да приключи естествено, когато не остане висяща работа.Да прихващаш всички грешки и да отговаряш винаги с една и съща техническа подробност. Клиентът се нуждае от стабилен и безопасен отговор; журналът се нуждае от контекст за диагностика. Раздели двете публики:
500може да казва{"detalle":"error interno"}, докато записът пази техническата грешка.
Упражнения
Упражнение 1 — Маршрут за версия
Добави маршрута GET /version към типизираното разпознаване на маршрути на проекта. Трябва да отговаря 200, текстова заглавна част и тяло revisor 1. Запази 404 за всеки друг маршрут и провери, че компилаторът ти сочи switch, докато не обслужиш новия клон. Провери и двата отговора с тест, който използва сървър на порт 0.
Упражнение 2 — Отчет с резюме
Добави към ReportePublico поле resumen: { disponibles: number; fallas: number } и го изчисли в aReportePublico. Обнови теста в reporte.test.ts, за да го проверява, и изпълни npm run verificar, за да видиш кои други файлове те принуждава компилаторът да пипнеш. Обясни защо и esReportePublico трябва да се промени.
Упражнение 3 — Още една променлива на средата
Добави REVISOR_MAX_SERVICIOS (по подразбиране 20) с leerEnteroPositivo и направи main.ts да отхвърля стартирането, с configuracion-invalida, ако servicios.json носи повече услуги от този лимит. Напиши тест с таблица от случаи за правилото.
Упражнение 4 — Спиране с максимално време
Затваряне, което чака заявка, която никога не свършва, оставя процеса увиснал. Промени detener в main.ts така, че ако cerrar(servidor) не завърши за 10 секунди, да записва събитието cierre-forzado и да извиква servidor.closeAllConnections(). Провери промяната си, като стартираш revisor, направиш curl към бавна цел и изпратиш SIGTERM.
Решения
Решение 1
Новият маршрут трябва да се появи както в типа, така и във функцията, която превръща външния текст, и тази функция продължава да минава през rutaDe: ако извикаш директно new URL, би възстановила дефекта със заявките с цел //. Да я оставиш само в една от двете части би дало непълен договор: обединението би казвало, че съществува, но никоя заявка не би могла да го достигне, или заявка би стигнала до клон, който TypeScript не признава за част от дизайна. С защитата never на switch пропускането на клона е грешка при компилация (TS2322), а не недоглеждане, което се открива в продукция.
type Ruta =
| { readonly tipo: "salud" }
| { readonly tipo: "version" }
| { readonly tipo: "estados" }
| { readonly tipo: "no-encontrada" };
function reconocerRuta(url: string | undefined): Ruta {
switch (rutaDe(url)) {
case "/salud":
return { tipo: "salud" };
case "/version":
return { tipo: "version" };
case "/api/estados":
return { tipo: "estados" };
default:
return { tipo: "no-encontrada" };
}
}
В atender добави case "version": със същия шаблон като salud: respuesta.writeHead(200, { "content-type": "text/plain; charset=utf-8" }), respuesta.end("revisor 1") и return. Тестът, в servidor.test.ts, проверява също, че различен маршрут получава 404; да тестваш само успешния път не потвърждава, че сървърът пази границата между известни и неизвестни маршрути.
test("GET /version responde el texto y una ruta parecida sigue en 404", async () => {
const api = crearServidor({
obtenerReporte: async () => ({ estados: [] }),
registrar: () => {},
});
await escuchar(api, 0);
const base = `http://127.0.0.1:${puertoDe(api)}`;
try {
const version = await fetch(`${base}/version`);
assert.equal(version.status, 200);
assert.equal(version.headers.get("content-type"), "text/plain; charset=utf-8");
assert.equal(await version.text(), "revisor 1");
const parecida = await fetch(`${base}/version/otra`);
assert.equal(parecida.status, 404);
await parecida.body?.cancel();
} finally {
await cerrar(api);
}
});
Решение 2
Промяната на типа и изчислението живеят заедно, а компилаторът върши останалата работа: всяко място, което изгражда ReportePublico без resumen, престава да се компилира.
export interface ReportePublico {
readonly resumen: { readonly disponibles: number; readonly fallas: number };
readonly estados: readonly EstadoPublico[];
}
export function aReportePublico(estados: readonly Estado[]): ReportePublico {
const publicos = estados.map(aEstadoPublico);
const disponibles = publicos.filter((estado) => estado.tipo === "disponible").length;
return {
resumen: { disponibles, fallas: publicos.length - disponibles },
estados: publicos,
};
}
Тук aEstadoPublico е функцията, която превръща едно-единствено Estado, тази, която преди беше написана вътре в map. esReportePublico трябва да проверява и resumen, защото работата ѝ е да описва при изпълнение точно това, което типът обещава при компилация; ако промениш само типа, защитата би приемала отговори, които типът вече не допуска, а таблото от урок 9 би се компилирало срещу договор, който никой не проверява.
Решение 3
Четенето на лимита е още един вход от средата и се третира като порта: малка функция, един Resultado, а main.ts решава какво да прави при отказа.
const maximo = leerEnteroPositivo("REVISOR_MAX_SERVICIOS", process.env.REVISOR_MAX_SERVICIOS, 20);
if (!maximo.ok) {
fallarArranque("configuracion-invalida", maximo.detalle);
return;
}
if (servicios.valor.length > maximo.valor) {
fallarArranque(
"configuracion-invalida",
`servicios.json trae ${servicios.valor.length} servicios y el máximo es ${maximo.valor}`,
);
return;
}
Правилото „повече услуги от максимума“ е чисто: удобно е да го извадиш във функция validarCantidad(servicios, maximo): Resultado<readonly Servicio[]> в configuracion.ts и да го тестваш с таблица (0, 1, максимумът, максимумът плюс едно), което е мястото, където живеят граничните грешки, вместо да го тестваш през main.ts.
Решение 4
Надпреварата е между две обещания: коректното спиране и един таймер. Ако таймерът спечели, връзките, които още са отворени, се затварят насила; това кара server.close да завърши.
const detener = (senal: string): void => {
cierre ??= (async () => {
registrar({ evento: "cierre", detalle: `${senal} recibida` });
const limite = setTimeout(() => {
registrar({ evento: "cierre-forzado", detalle: "pasaron 10 s con solicitudes abiertas" });
servidor.closeAllConnections();
}, 10_000);
try {
await cerrar(servidor);
} finally {
clearTimeout(limite);
}
registrar({ evento: "cerrado", detalle: "el servidor dejó de aceptar conexiones" });
})();
};
.catch(...), който main.ts прикрепя в края на израза, се запазва както е; тук е пропуснат, за да покаже само това, което се променя. finally отменя таймера, когато затварянето е завършило навреме: без него таймерът би държал процеса жив още десет секунди, макар вече да не е нужен. closeAllConnections() прекъсва заявките в ход, така че е последна мярка и затова се записва като отделно събитие: който чете журнала, трябва да може да разграничи чисто затваряне от принудително.
Как разбирам, че съм успял
-
npx tsc --versionотпечатваVersion 7.0.2вътре в~/proyectos/revisor. - В папката
figuras/fig08_01.tsсе компилира с--types node, отпечатва200 okи процесът приключва сам, след като затвори сървъра си. -
fig08_03.tsотхвърля65536,0иholaс подробност, която назовава променливатаPUERTO, и приема липсата с3000. -
fig08_04.tsотпечатваcierreпредиclienteиcerradoнакрая; заявката в ход получава отговора си. - Когато компилираш фигурата за маршрута с
/api/estado, се появява TS2345; когато компилираш фигурата заsolicitud.url, се появява TS18048. - В проекта
npm run verificar,npm run lintиnpm run formatoзавършват без предупреждения, аnpm run probarдокладва 19 одобрени теста и 0 неуспешни. -
PUERTO=3100 npm run arrancarзаписваescuchando;curl http://127.0.0.1:3100/api/estadosвръща JSON сnombreиtipoза всяка услуга и безurl;Ctrl+Cзаписваcierreиcerrado. -
PUERTO=hola npm run arrancarприключва с код на изход 1 и събитиетоconfiguracion-invalida, без да отвори никакъв порт.
За допълнително четене
Node.js: HTTP — официална документация за
createServer, заявките, отговорите,listenиclose; консултирано на 2 октомври 2026 г.Node.js: Process — официална документация за сигналите на процеса,
SIGTERMи жизнения цикъл на Node; консултирано на 2 октомври 2026 г.TypeScript Handbook: Narrowing — официална документация за стесняването на дискриминирани обединения, проверките на незадължителни стойности и изчерпателността с
never; консултирано на 2 октомври 2026 г.MDN: HTTP response status codes — справочник за кодовете на състоянието на HTTP и значението им за клиентите и сървърите; консултирано на 2 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx