Hono, jeden kod na wszystkie środowiska uruchomieniowe
Framework webowy pisany kilkanaście lat temu zakładał, że działa na serwerze z Node. Dziś kod trafia też na brzeg sieci, do funkcji bezserwerowych i do środowisk, w których nie ma modułów systemowych. Hono zbudowano wokół standardowego interfejsu żądania i odpowiedzi, więc ten sam kod działa wszędzie, gdzie ten standard obowiązuje.
Framework waży kilkanaście kilobajtów po minifikacji, nie ma zależności i dostarcza typowanego klienta, dzięki któremu front zna kształt odpowiedzi bez generowania kodu. To trzy rzeczy, które w praktyce decydują o jego wyborze.
Pierwsza trasa
pnpm add honoimport { Hono } from 'hono'
const app = new Hono()
app.get('/zadania/:id', async (c) => {
const id = c.req.param('id')
const zadanie = await pobierzZadanie(id)
if (!zadanie) return c.json({ blad: 'Nie znaleziono' }, 404)
return c.json(zadanie)
})
export default appObiekt kontekstu łączy żądanie i odpowiedź w jednym miejscu, więc nie przekazuje się dwóch osobnych argumentów. Metody zwracające odpowiedź są typowane, a zwracany obiekt jest standardową odpowiedzią, nie własną strukturą frameworka.
Uruchomienie zależy od środowiska i to jedyna rzecz, która się różni. W funkcji brzegowej eksportujesz aplikację, w Node dokładasz adapter serwera, w Bun i Deno wystarcza sam eksport. Ta jedna linia jest zwykle całą różnicą między wdrożeniem lokalnym a produkcyjnym.
Walidacja i typy
Walidację wpina się jako warstwę pośrednią, a wynik trafia do uchwytu z zachowanymi typami.
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const schemat = z.object({
tytul: z.string().min(1).max(200),
termin: z.string().datetime().optional()
})
app.post('/zadania', zValidator('json', schemat), async (c) => {
const dane = c.req.valid('json')
const zadanie = await utworzZadanie(dane)
return c.json(zadanie, 201)
})Metoda odczytująca zwalidowane dane zna typ wynikający ze schematu, więc odwołanie do pola spoza niego jest błędem kompilacji. Błędne żądanie zostaje odrzucone przed wejściem do uchwytu, z odpowiedzią opisującą, co nie pasuje.
Ten układ usuwa najczęstszą klasę błędów w interfejsach programistycznych: dane wchodzące bez sprawdzenia, obsługiwane potem warunkami rozsianymi po kodzie. Schemat jest przy okazji dokumentacją, bo mówi wprost, czego punkt końcowy oczekuje.
Klient RPC
To rzecz, która odróżnia ten framework od większości alternatyw. Typ aplikacji da się wyeksportować, a klient po stronie frontu odczytuje z niego ścieżki, parametry i kształt odpowiedzi.
export type AppType = typeof trasyimport { hc } from 'hono/client'
import type { AppType } from '../serwer'
const klient = hc<AppType>('https://api.example.com')
const odpowiedz = await klient.zadania[':id'].$get({ param: { id: '118' } })
const zadanie = await odpowiedz.json()Front zna wtedy kształt odpowiedzi bez generowania kodu i bez osobnego pliku ze schematem. Zmiana pola po stronie serwera natychmiast wywołuje błąd kompilacji tam, gdzie to pole jest używane.
Warunkiem działania jest wspólna baza kodu albo współdzielony pakiet z typami. W układzie, gdzie front i zaplecze są osobnymi repozytoriami bez wspólnych typów, ta możliwość odpada i wracasz do klasycznego opisu interfejsu.
Warstwy pośrednie
Warstwa pośrednia to funkcja przyjmująca kontekst i wywołanie następnego kroku, więc kod przed wywołaniem dzieje się przed uchwytem, a po nim po uchwycie.
app.use('*', async (c, next) => {
const start = performance.now()
await next()
c.header('X-Czas', `${Math.round(performance.now() - start)}ms`)
})
app.use('/api/*', jwt({ secret: process.env.JWT_SECRET! }))Framework dostarcza gotowe warstwy do najczęstszych zadań: uwierzytelnianie podstawowe i tokenem, obsługa polityki pochodzenia, zabezpieczenie przed fałszowaniem żądań, nagłówki bezpieczeństwa, kompresja, limit rozmiaru treści, limity czasu i ograniczenia po adresie.
Kolejność ma znaczenie i to najczęstsze źródło pomyłek. Warstwa uwierzytelniająca wpięta po trasie publicznej nie zadziała dla niej, a warstwa obsługi polityki pochodzenia wpięta po uchwycie nie doda nagłówków do odpowiedzi, którą uchwyt już zwrócił.
Środowiska uruchomieniowe
To główny argument za tym rozwiązaniem, więc warto rozumieć, co dokładnie oznacza.
Kod oparty o standardowy interfejs żądania działa bez zmian tam, gdzie ten standard jest dostępny: w środowisku brzegowym, w Bunie, w Deno, w Node z adapterem i w funkcjach bezserwerowych u głównych dostawców. Zmiana środowiska sprowadza się do zmiany sposobu uruchomienia, a nie do przepisywania tras.
Granica przebiega przy modułach systemowych. Kod sięgający po system plików albo po procesy działa w Node, a w środowisku brzegowym nie, bo tam ich po prostu nie ma. Framework tego nie naprawi, więc przy planowaniu wdrożenia na brzeg sieci trzeba to sprawdzić w zależnościach, nie tylko we własnym kodzie.
Rozmiar paczki ma tu praktyczne znaczenie. Środowiska brzegowe mają limity rozmiaru i czasu zimnego startu, więc framework bez zależności zostawia więcej miejsca na logikę aplikacji. Przy Cloudflare to bywa różnica między wdrożeniem, które się mieści, a takim, które nie.
Grupowanie tras i struktura projektu
Aplikacja z trzema trasami mieści się w jednym pliku, a z trzydziestoma już nie. Framework pozwala składać instancje, więc każdy obszar żyje osobno i wpina się pod wspólny prefiks.
import { Hono } from 'hono'
import { zadania } from './trasy/zadania'
import { uzytkownicy } from './trasy/uzytkownicy'
const app = new Hono()
.route('/zadania', zadania)
.route('/uzytkownicy', uzytkownicy)
export type AppType = typeof appŁańcuchowe wywołania mają tu znaczenie techniczne, a nie estetyczne. Typ aplikacji powstaje z tego łańcucha, więc przypisanie tras w osobnych instrukcjach gubi informację potrzebną klientowi z typami. To najczęstsza przyczyna sytuacji, w której klient nie widzi części tras.
Obsługę błędów warto ustawić centralnie, zamiast powtarzać ją w każdym uchwycie.
app.onError((err, c) => {
if (err instanceof BladWalidacji) return c.json({ blad: err.message }, 400)
console.error(err)
return c.json({ blad: 'Blad serwera' }, 500)
})
app.notFound((c) => c.json({ blad: 'Nie znaleziono' }, 404))Dzięki temu każda odpowiedź błędna ma ten sam kształt, a klient nie musi obsługiwać kilku formatów. To drobiazg, który przy dwudziestu trasach oszczędza sporo kodu po drugiej stronie.
Wydajność i koszty w środowisku bezserwerowym
Wybór lekkiego frameworka ma sens finansowy, a nie tylko techniczny, i warto wiedzieć dlaczego.
W środowisku bezserwerowym płacisz za czas wykonania, a ten obejmuje zimny start. Im mniejsza paczka, tym krótszy czas ładowania kodu przed obsłużeniem pierwszego żądania. Przy funkcji wywoływanej rzadko zimny start dotyczy większości wywołań, więc różnica jest odczuwalna w rachunku.
Druga sprawa to limity rozmiaru. Środowiska brzegowe ograniczają wielkość wdrożenia, a każdy megabajt zajęty przez framework to megabajt mniej na zależności aplikacji. Projekt, który się nie mieści, wymaga podziału na kilka funkcji, co komplikuje wdrożenie.
Trzecia to sam czas obsługi żądania. Router dopasowujący ścieżkę bez przeszukiwania liniowej listy tras działa szybciej przy dużej liczbie tras, choć przy dwudziestu ścieżkach ta różnica jest zwykle poniżej progu zauważalności.
Praktyczny wniosek: przy funkcji obsługującej milion żądań miesięcznie warto zmierzyć czas zimnego startu przed wdrożeniem i po nim. Przy funkcji obsługującej tysiąc żądań ta optymalizacja nie ma znaczenia i lepiej wybrać framework, który zespół zna.
Hono kontra alternatywy
| Framework | Mocna strona | Słabość | Kiedy wybrać |
|---|---|---|---|
| Hono | Lekkość, wiele środowisk, typowany klient | Mniejszy ekosystem niż u weterana | Interfejsy programistyczne, brzeg sieci, funkcje bezserwerowe |
| Express | Największy ekosystem, wszędzie znany | Ciężki, tylko Node, słabe typowanie | Istniejący projekt na Node |
| Fastify | Wydajność w Node, dojrzałe wtyczki | Tylko Node | Zaplecze w Node o dużym ruchu |
| Next.js | Front i zaplecze razem | Nadmiarowy do samego interfejsu | Aplikacja z interfejsem użytkownika |
Wybór między pierwszymi dwoma wierszami zależy od tego, gdzie kod ma działać i czy zależy Ci na typach. Przy projekcie na Node z dużym ekosystemem wtyczek klasyczne rozwiązanie nadal jest w porządku. Przy nowym interfejsie programistycznym, który może trafić na brzeg sieci, lekkość i przenośność wygrywają.
Ostatni wiersz warto rozważyć, gdy projekt i tak ma front. Trasy interfejsu w ramach jednej aplikacji oszczędzają osobne wdrożenie, choć przy zapleczu rosnącym w osobny produkt rozdzielenie prędzej czy później i tak nastąpi.
Przy porównaniu warto oddzielić dwie rzeczy, które łatwo pomylić. Wydajność samego frameworka rzadko jest wąskim gardłem, bo czas obsługi żądania idzie głównie na zapytania do bazy i wywołania zewnętrzne. Znaczenie ma natomiast to, gdzie kod może działać, bo tego nie da się nadrobić optymalizacją. Framework przywiązany do jednego środowiska zamyka drogę do wdrożenia na brzegu sieci, nawet gdy reszta architektury na to pozwala.
Drugą rzeczą jest znajomość narzędzia w zespole. Framework nieznany nikomu poza jedną osobą to ryzyko, które przy prostym interfejsie programistycznym rzadko się opłaca. Warto to zważyć przed decyzją, bo różnice techniczne są tu mniejsze niż koszt uczenia się przy pilnym zgłoszeniu.
Zmienne środowiskowe i dostęp do zasobów
Sposób dostępu do konfiguracji różni się między środowiskami i to jedna z niewielu rzeczy, których framework nie ukrywa całkowicie.
W Node zmienne czytasz z obiektu procesu, a w środowisku brzegowym przychodzą one w kontekście żądania, bo funkcja nie ma globalnego procesu. Framework udostępnia je przez kontekst, więc kod przenośny odczytuje je stamtąd.
type Srodowisko = {
Bindings: {
DATABASE_URL: string
KLUCZ_API: string
}
}
const app = new Hono<Srodowisko>()
app.get('/dane', async (c) => {
const polaczenie = polacz(c.env.DATABASE_URL)
return c.json(await polaczenie.pobierz())
})Typowanie środowiska ma tu praktyczną wartość: literówka w nazwie zmiennej jest błędem kompilacji, a nie wartością nieokreśloną odkrytą na produkcji.
Druga sprawa to połączenia do bazy. W Node połączenie tworzy się raz i żyje w module, bo proces trwa. W środowisku bezserwerowym każda instancja funkcji ma własne, więc pula połączeń po stronie bazy wyczerpuje się szybciej, niż wynikałoby z ruchu. Rozwiązaniem jest sterownik działający po protokole HTTP albo pula po stronie dostawcy bazy, tak jak przy Drizzle w tym samym układzie.
Trzecia to sekrety. Klucze trzymaj w konfiguracji dostawcy, nie w pliku wdrażanym razem z kodem, bo ten trafia do środowiska, w którym kilka osób ma dostęp do podglądu.
Typowe błędy
Pierwszy to używanie modułów systemowych w kodzie przeznaczonym na brzeg sieci. Działa lokalnie w Node i przestaje działać po wdrożeniu, a komunikat błędu rzadko wskazuje przyczynę wprost.
Drugi to zła kolejność warstw pośrednich. Warstwa uwierzytelniająca wpięta po trasie nie chroni jej, co jest luką, a nie usterką wygody.
Trzeci to brak walidacji wejścia. Framework nie sprawdzi za Ciebie treści żądania, więc bez schematu dane wchodzą do uchwytu w dowolnym kształcie.
Czwarty to zwracanie błędów bez ustalonego formatu. Odpowiedź błędna raz jako tekst, raz jako obiekt zmusza klienta do zgadywania, więc warto ustalić jeden kształt i trzymać go we wspólnej warstwie.
Piąty to zakładanie, że klient z typami zadziała między osobnymi repozytoriami. Bez wspólnego pakietu z typami front nie ma skąd wziąć definicji i trzeba wrócić do opisu interfejsu.
Szósty to pomijanie limitu rozmiaru treści. Punkt końcowy przyjmujący plik bez ograniczenia to prosty sposób na wyczerpanie pamięci albo limitu w środowisku bezserwerowym.
Strumieniowanie i zdarzenia
Interfejs oparty o standard żądania i odpowiedzi daje strumieniowanie bez dodatkowych bibliotek, co przydaje się przy aplikacjach z modelem językowym.
import { streamSSE } from 'hono/streaming'
app.get('/asystent', (c) => {
return streamSSE(c, async (strumien) => {
for await (const fragment of odpowiedzModelu(c.req.query('pytanie')!)) {
await strumien.writeSSE({ data: fragment })
}
})
})Odpowiedź modelu potrafi powstawać kilkanaście sekund, więc bez strumieniowania użytkownik patrzy na pusty ekran. Zdarzenia wysyłane przez serwer wystarczają w większości przypadków i są prostsze od gniazd sieciowych, bo działają po zwykłym połączeniu i same wznawiają się po zerwaniu.
Framework obsługuje też gniazda sieciowe tam, gdzie środowisko na to pozwala, przy czym warto pamiętać o różnicach. Nie każde środowisko brzegowe je udostępnia, a nawet gdy tak, długo trwające połączenie kłóci się z modelem rozliczania za czas wykonania.
Praktyczna zasada: zdarzenia od serwera do przesyłania danych w jedną stronę, gniazda dopiero wtedy, gdy klient musi wysyłać w trakcie połączenia. Pierwszy przypadek pokrywa strumieniowanie odpowiedzi, powiadomienia i paski postępu, czyli większość zastosowań.
FAQ
Czym Hono różni się od Express?
Jest znacznie lżejszy, oparty o standardowy interfejs żądania i odpowiedzi, więc działa nie tylko w Node, ale też w środowisku brzegowym, w Bunie i Deno. Ma też typowanie od trasy do klienta, czego klasyczne rozwiązanie nie oferuje bez dodatkowych warstw.
Czy Hono nadaje się na produkcję?
Tak, framework jest stabilny, ma gotowe warstwy pośrednie do uwierzytelniania, nagłówków bezpieczeństwa, limitów i obsługi polityki pochodzenia. Warto natomiast pamiętać, że ekosystem wtyczek jest mniejszy niż u rozwiązań obecnych na rynku od kilkunastu lat.
Czy da się używać Hono z Next.js?
Tak, można obsłużyć trasy interfejsu programistycznego tym frameworkiem wewnątrz aplikacji w Next.js. Sensowniejsze bywa jednak rozdzielenie: interfejs jako osobne wdrożenie, gdy zaplecze rośnie w samodzielny produkt.
Co daje klient RPC?
Front zna ścieżki, parametry i kształt odpowiedzi bezpośrednio z typu aplikacji serwerowej, bez generowania kodu i bez osobnego pliku ze schematem. Warunkiem jest współdzielenie typów, więc rozwiązanie działa w jednej bazie kodu albo przy wspólnym pakiecie.
Gdzie Hono nie zadziała?
W kodzie wymagającym modułów systemowych uruchamianym w środowisku bez nich, na przykład na brzegu sieci. Framework działa tam poprawnie, ale Twój kod sięgający po system plików albo procesy już nie, więc zależności trzeba sprawdzić przed wdrożeniem.
Dokumentacja stoi na stronie projektu, a kod źródłowy w repozytorium na GitHubie.