Innentől a szerveroldalról szól a tananyag. A jó hír: a h3 mentális modellje szinte azonos az Expressével, csak a nevek mások és a fájlszerkezet veszi át a router szerepét. Ez a modul egy teljes Express → h3 megfeleltetést ad, hogy a meglévő API-d mintáit sorról sorra át tudd fordítani — plusz azt a hármat, ami tényleg máshogy működik.
server/
api/
projects/
index.get.ts → GET /api/projects
index.post.ts → POST /api/projects
[id].get.ts → GET /api/projects/:id
[id].patch.ts → PATCH /api/projects/:id
[id].delete.ts → DELETE /api/projects/:id
[id]/archive.post.ts→ POST /api/projects/:id/archive
health.ts → minden metódus a /api/health-en
routes/
sitemap.xml.get.ts → GET /sitemap.xml ← NINCS /api prefix
webhooks/
stripe.post.ts → POST /webhooks/stripe
middleware/
1.request-id.ts minden kérésnél fut
2.tenant.ts
utils/
db.ts auto-import, csak szerveroldalon
session.ts
plugins/
error-logging.ts Nitro-szintű plugin (nem app-plugin!)
Két dolog, ami elsőre nem nyilvánvaló:
server/api/ vs. server/routes/: az előbbi automatikusan /api prefixet kap, az utóbbi nem. Ami nem a saját frontendednek szól — webhookok, sitemap.xml, robots.txt, OAuth callback —, az a routes/ alá való.health.ts), akkor minden metódusra válaszol. Ez ritkán az, amit akarsz — a metódus-utótag egyben dokumentáció is.event és a h3-eszköztárExpressben két objektumot kapsz (req, res), és rajtuk hívsz metódusokat. h3-ban egy objektumot kapsz (event), és függvényeket hívsz rá. Ennyi a különbség — de emiatt más lesz a kód formája:
router.patch('/:id', requireAuth, async (req, res, next) => {
try {
const { name } = req.body
if (!name) return res.status(422).json({ error: 'name kötelező' })
const updated = await db.project.update({
where: { id: req.params.id, tenantId: req.session.tenantId },
data: { name },
})
res.set('X-Request-Id', req.id)
res.json(updated)
} catch (err) { next(err) }
})
server/api/projects/[id].patch.tsexport default defineEventHandler(async (event) => {
const { tenantId } = await requireSession(event) // server/utils, auto-import
const id = getRouterParam(event, 'id')!
const body = await readValidatedBody(event, updateProjectSchema.parse)
const updated = await useDb(event).update(projects)
.set({ name: body.name })
.where(and(eq(projects.id, id), eq(projects.tenantId, tenantId)))
.returning()
if (!updated.length) throw createError({ statusCode: 404, statusMessage: 'Nincs ilyen projekt' })
setResponseHeader(event, 'X-Request-Id', event.context.requestId)
return toProjectDto(updated[0]) // a visszatérési érték a válasz teste
})
Négy szerkezeti különbség, amit érdemes rögtön beépíteni a reflexbe:
next(err) és nincs try/catch boilerplate. Dobsz egy createError-t, és a Nitro elintézi. A nem várt kivételeket is elkapja és 500-ra fordítja.ReadableStream-ből stream. Nincs res.json().requireAuth nem middleware, hanem egy függvény, amit meghívsz. Ez explicitebb, és látszik a kódban, hogy mi fut le.routes/projects.js.| Express | h3 / Nitro |
|---|---|
| app.get('/x', h) | server/api/x.get.ts |
| req.params.id | getRouterParam(event, 'id') |
| req.query | getQuery(event) |
| req.body (express.json()) | await readBody(event) |
| — (nyers törzs) | await readRawBody(event) |
| req.headers['x'] | getHeader(event, 'x') |
| req.cookies.x (cookie-parser) | getCookie(event, 'x') |
| res.cookie(n, v, o) | setCookie(event, n, v, o) |
| res.clearCookie(n) | deleteCookie(event, n) |
| res.json(obj) | return obj |
| res.status(201).json(obj) | setResponseStatus(event, 201); return obj |
| res.set('X', v) | setResponseHeader(event, 'X', v) |
| res.redirect(302, url) | return sendRedirect(event, url, 302) |
| next(err) / throw | throw createError({ statusCode, statusMessage }) |
| app.use(mw) | server/middleware/*.ts |
| multer | await readMultipartFormData(event) |
| cors() | routeRules: { '/api/**': { cors: true } } |
| express.static('public') | public/ → Workers static assets |
| req.ip | getRequestIP(event) — CF-en lásd lentebb |
| req.protocol + req.get('host') | getRequestURL(event) / getRequestHost(event) |
| res.locals | event.context |
CF-Connecting-IP fejlécben adja — ha IP-re építesz (rate limit, audit napló), ezt olvasd, ne a x-forwarded-for-t. ② A bindingok és az ExecutionContext az event.context.cloudflare alatt vannak: event.context.cloudflare.env.MY_KV, illetve event.context.cloudflare.context.waitUntil(promise). Ez utóbbi azért fontos, mert így tudsz „a válasz után is fusson” munkát indítani anélkül, hogy a felhasználó várna rá — a Cloudflare-anyag 2. és 7. modulja ezt részletezi.Expressben ezt jellemzően middleware-rel oldottad meg (express-validator, celebrate). h3-ban egy függvényhívás, és a típus is jön vele:
// shared/schemas/project.ts — mindkét oldal használhatja (2. modul: shared/)
import { z } from 'zod'
export const createProjectSchema = z.object({
name: z.string().min(1).max(120),
description: z.string().max(2000).optional(),
status: z.enum(['active', 'archived']).default('active'),
})
export type CreateProject = z.infer<typeof createProjectSchema>
// server/api/projects/index.post.ts
export default defineEventHandler(async (event) => {
const { tenantId, userId } = await requireSession(event)
// hiba esetén automatikusan 400-at dob — a body típusos lesz
const body = await readValidatedBody(event, createProjectSchema.parse)
// query-re ugyanez: getValidatedQuery(event, listQuerySchema.parse)
const [row] = await useDb(event).insert(projects)
.values({ ...body, tenantId, createdBy: userId })
.returning()
setResponseStatus(event, 201)
return toProjectDto(row)
})
safeParse-szal magad formázd:
const raw = await readBody(event)
const parsed = createProjectSchema.safeParse(raw)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Érvénytelen adat',
data: { errors: parsed.error.flatten().fieldErrors },
})
}
A kliens ezt az 5. modulban látott módon kapja meg: catch (e) { e.data.errors }.throw createError({
statusCode: 403,
statusMessage: 'Nincs jogosultság', // ✓ a kliens LÁTJA
data: { needed: 'admin' }, // ✓ a kliens LÁTJA
message: 'user 42 lacks role on tenant 7', // belső — élesben nem megy ki
fatal: false, // true → az egész oldal a hibaoldalra megy
})
// server/plugins/errors.ts — Nitro-plugin, nem app-plugin
export default defineNitroPlugin((nitro) => {
nitro.hooks.hook('error', (error, { event }) => {
// a 4xx-eket ne zajongd tele — csak a valódi hibákat
const code = (error as any).statusCode ?? 500
if (code < 500) return
console.error('[hiba]', {
requestId: event?.context.requestId,
tenant: event?.context.tenant?.slug,
path: event?.path,
message: (error as Error).message,
})
})
})
console.error kimenetét a Workers Logs gyűjti (Cloudflare-anyag 9. modul), és ha itt strukturáltan írsz — request-azonosító, tenant, útvonal —, akkor a hibakeresés kereshető lesz. Amit itt ne írj ki: személyes adatot és teljes kérés-törzset.A server/middleware/ alatti fájlok minden szerverre érkező kérésnél lefutnak — az SSR-nél és a /api-hívásoknál is. A sorrendet a fájlnevek adják (ábécésorrend), ezért szokás számmal prefixálni:
// server/middleware/1.request-id.ts
export default defineEventHandler((event) => {
event.context.requestId =
getHeader(event, 'cf-ray') ?? crypto.randomUUID()
})
// server/middleware/2.tenant.ts (4. modul)
export default defineEventHandler(async (event) => {
event.context.tenant = await resolveTenant(event)
})
next() hiánya az, ami elakasztja a láncot; h3-ban fordítva: ha visszaadsz bármit (akár egy véletlen return true-t), az lesz a válasz, és a tényleges handler már le sem fut. Ez egy csendes, nehezen megtalálható hiba — figyelj rá, hogy a middleware-eid ne térjenek vissza semmivel.server/utils és a rétegekA server/utils/ auto-importált és csak szerveroldalon létezik — itt lakik minden, ami nem HTTP-kezelés. Érdemes három réteget elkülöníteni, még ha kicsi is a projekt:
| Réteg | Hol | Mit csinál — és mit NEM |
|---|---|---|
| Handler | server/api/** | HTTP: olvas, validál, státuszkódot ad, DTO-t formáz. Nincs benne üzleti logika. |
| Service | server/utils/services/ | Üzleti szabályok, tranzakciók, több lépéses műveletek. Nem ismer event-et, csak paramétereket. |
| Adathozzáférés | server/utils/db.ts, repók | Lekérdezések. A tenant-szűrés itt kötelező, nem opció. |
// server/api/projects/index.post.ts — a handler vékony
export default defineEventHandler(async (event) => {
const { tenantId, userId } = await requireSession(event)
const body = await readValidatedBody(event, createProjectSchema.parse)
const project = await createProject(useDb(event), { tenantId, userId, ...body })
setResponseStatus(event, 201)
return toProjectDto(project)
})
event-et, akkor bármelyik irányba mozgatható: hívhatja egy Nitro-handler, egy Express-controller, egy queue-consumer vagy egy cron. Ha viszont az üzleti logika beleragad a handlerbe, akkor a döntésed visszafordíthatatlan lesz.// server/routes/webhooks/stripe.post.ts
export default defineEventHandler(async (event) => {
// FONTOS: nyers törzs kell az aláírás-ellenőrzéshez — a readBody() már JSON-t ad,
// és az újraszerializálva NEM egyezik bájtra az eredetivel
const raw = await readRawBody(event, 'utf8')
const sig = getHeader(event, 'stripe-signature')
if (!raw || !sig || !(await verifySignature(raw, sig, useRuntimeConfig(event).stripeSecret))) {
throw createError({ statusCode: 400, statusMessage: 'Érvénytelen aláírás' })
}
const payload = JSON.parse(raw)
// gyors 200, a munka a háttérben — Workers-en waitUntil vagy Queue
event.context.cloudflare.context.waitUntil(handleStripeEvent(payload))
return { received: true }
})
// server/api/uploads.post.ts
export default defineEventHandler(async (event) => {
const { tenantId } = await requireSession(event)
const parts = await readMultipartFormData(event)
const file = parts?.find(p => p.name === 'file')
if (!file) throw createError({ statusCode: 422, statusMessage: 'Hiányzó fájl' })
if (file.data.length > 10_000_000) {
throw createError({ statusCode: 413, statusMessage: 'Túl nagy fájl' })
}
// a kulcs ELSŐ szegmense a tenant — így a jogosultság az útból is látszik
const key = `${tenantId}/${crypto.randomUUID()}-${sanitize(file.filename!)}`
await event.context.cloudflare.env.BUCKET.put(key, file.data, {
httpMetadata: { contentType: file.type ?? 'application/octet-stream' },
})
return { key }
})
readMultipartFormData a memóriába olvas. Néhány MB-os képnél rendben van; nagyobbnál presigned URL a helyes minta: a szerver csak egy aláírt feltöltési URL-t ad, a böngésző pedig közvetlenül az R2-be tölt fel. Ez a Cloudflare-anyag 6. moduljának témája.// server/api/export.get.ts — nagy CSV, nem a memóriából
export default defineEventHandler(async (event) => {
const { tenantId } = await requireSession(event)
setResponseHeader(event, 'content-type', 'text/csv; charset=utf-8')
setResponseHeader(event, 'content-disposition', 'attachment; filename="export.csv"')
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder()
controller.enqueue(enc.encode('id,nev,statusz\n'))
for await (const row of iterateProjects(useDb(event), tenantId)) {
controller.enqueue(enc.encode(`${row.id},${csv(row.name)},${row.status}\n`))
}
controller.close()
},
})
return stream // a ReadableStream visszaadható válaszként
})
A h3 következő major verziója (v2) a Nitro v3-mal, vagyis a Nuxt 5-tel érkezik, és néhány segédfüggvény neve, illetve a belső Request/Response-kezelés változik. Amit most tehetsz, hogy ne fájjon:
get*/set*/read* segédfüggvények stabilak; az event.node.req-hez nyúlás nem — és Workers-en amúgy sem mindig van értelme.server/routes/ alá a server/api/ helyett?
Amikor a végpont nem a saját frontendednek szól, és az URL-jét nem te választod meg szabadon: webhookok, sitemap.xml, robots.txt, OAuth-callback, jól ismert (/.well-known/…) útvonalak. A server/api/ automatikusan /api prefixet kap, a server/routes/ nem.
Hogy a middleware visszaad valamit. h3-ban a middleware visszatérési értéke lesz a válasz, és a lánc megszakad. Expressben a next() elfelejtése akasztja el a láncot; itt fordítva: a middleware ne térjen vissza semmivel, ha folytatni akarod.
readBody()-t webhook-aláírás ellenőrzéséhez?
Mert a readBody() már feldolgozott (parse-olt) adatot ad, és ha újra szerializálod, a bájtok nem feltétlenül egyeznek az eredetivel — a kulcsok sorrendje, a szóközök, a számformátum eltérhet. Az aláírás viszont a nyers bájtokra készült. Ezért readRawBody(event) kell, és a JSON-t abból parse-olod.
A bindingot az event.context.cloudflare.env.MY_KV-n keresztül, a háttérmunkát pedig az event.context.cloudflare.context.waitUntil(promise)-szal — így a válasz azonnal elmegy, a promise viszont még lefut. Tartós vagy több lépéses munkára inkább Queue vagy Workflow való (Cloudflare-anyag 7. modul).
event-et?
Mert így ugyanaz a logika hívható HTTP-handlerből, queue-consumerből, cronból — és ha a 9. modulban úgy döntesz, hogy mégis marad külön API, akkor a logika mozgatható. Ha az üzleti szabályok a handlerbe ragadnak, az architekturális döntésed visszafordíthatatlanná válik.
Mert a 403 megerősíti, hogy az erőforrás létezik — csak épp nincs hozzá jogod. Ebből azonosítók és nagyságrendek derülnek ki. Ha a felhasználó nem tagja az adott tenantnak, adj 404-et; a 403-at tartsd fenn arra az esetre, amikor a tenanton belül nincs meg a szerepköre.