H3Event
H3Event, carries incoming request, prepared response and context.
With each HTTP request, H3 internally creates an H3Event object and passes it though event handlers until sending the response.
An event is passed through all the lifecycle hooks and composable utils to use it as context.
Example:
app.get("/", async (event) => {
// Log HTTP request
console.log(`[${event.req.method}] ${event.req.url}`);
// Parsed URL and query params
const searchParams = event.url.searchParams;
// Try to read request JSON body
const jsonBody = await event.req.json().catch(() => {});
return "OK";
});#H3Event Methods
#H3Event.waitUntil
Tell the runtime about an ongoing operation that shouldn't close until the promise resolves.
import { logRequest } from "./tracing.mjs";
app.get("/", (event) => {
request.waitUntil(logRequest(request));
return "OK";
});Tip
To release per-request resources (timers, upstream connections, file handles) once the event is fully over — on every runtime — use the onDispose(event, cb) utility.
#H3Event Properties
#H3Event.app?
Access to the H3 application instance.
#H3Event.context
The context is an object that contains arbitrary information about the request.
You can store your custom properties inside event.context to share across utils.
Known context keys:
context.params: Matched router parameters.middlewareParams: Matched middleware parametersmatchedRoute: Matched router route object.sessions: Cached session data.basicAuth: Basic authentication data.
#H3Event.req
Incoming HTTP request info based on native Web Request with additional runtime addons (see srvx docs).
app.get("/", async (event) => {
const url = event.req.url;
const method = event.req.method;
const headers = event.req.headers;
// (note: you can consume body only once with either of this)
const bodyStream = await event.req.body;
const textBody = await event.req.text();
const jsonBody = await event.req.json();
const formDataBody = await event.req.formData();
return "OK";
});#H3Event.url
Access to the full parsed request URL.
app.get("/", (event) => {
const { pathname, search, searchParams } = event.url;
return "OK";
});#Pathname encoding
event.url.pathname is the path in its wire encoding, with one exception: an escape that is needlessly there is dropped.
An escape is needless when every decoding consumer — a proxy, a filesystem lookup, a handler calling decodeURIComponent — reads it as its literal, while H3's matchers compare the two as different strings. Left alone, that gap lets /%61dmin slip past an /admin guard and still reach the /admin route, or reach a catch-all handler that decodes it downstream. So H3 decodes exactly those escapes, once, before routing. Nothing else is touched:
| Request | event.url.pathname | Why |
|---|---|---|
/%61dmin | /admin | Unreserved escape, needlessly encoded |
/a%2eb | /a.b | Unreserved escape, needlessly encoded |
/a%21b | /a!b | Decodes downstream, needlessly encoded |
/%40handle | /@handle | Decodes downstream, needlessly encoded |
/a/%2e%2e/b | /b | The URL parser resolved the dot segment first |
/x%2fy | /x%2fy | Separator, must stay encoded |
/x%5cy | /x%5cy | Separator, must stay encoded |
/100%25 | /100%25 | Decoding it would expose a nested escape |
/a%20b | /a%20b | The URL serializer re-encodes a space anyway |
/caf%C3%A9 | /caf%C3%A9 | The URL serializer re-encodes non-ASCII anyway |
The decoded set is every escape whose character survives WHATWG path serialization unchanged, minus %2f and %25: the RFC 3986 §2.3 unreserved set (ALPHA / DIGIT / - / . / _ / ~), which is equivalent to its literal per §6.2.2.2, plus !, $, &, ', (, ), *, +, ,, :, ;, =, @, [, ] and |. Anything else is left alone because decoding it cannot survive the round trip (%20, %5E, %7B, non-ASCII), would change how many segments the path has (%2f, %5c), would delete the character outright (%09 and the other C0 controls), or would expose a nested escape (%25).
That is wider than decodeURI, which preserves all of RFC 3986's reserved set (; / ? : @ & = + $ ,) — of which only / is structural in an already-parsed path. Routes like /@handle or /resource:action are ordinary, so a guard protecting one must not be walkable past by its escaped spelling (/%40handle).
Route matching, use() matchers and your own event.url.pathname checks therefore all compare one and the same string. event.req.url always keeps the original wire encoding, so for a non-canonical path the two disagree: read the path from event.url, and never re-derive it from event.req.url — slicing one by an offset taken from the other is how mount prefixes and proxy targets desync.
Canonicalization happens in the H3Event constructor, so it covers every event, including one built by mockEvent() or a standalone handler.fetch().
Every escape that survives is opaque — treat it that way:
Warning
Never decode event.url.pathname yourself. Decoding can reintroduce a / or .. that routing and middleware never saw, which is a path traversal vector when the value reaches a filesystem or an upstream URL. To read a route param in decoded form, use getRouterParams(event, { decode: true }), which decodes everything else but keeps encoded separators encoded. To canonicalize a path for a scope check, use resolveDotSegments.
A route param therefore can never contain a path separator the router did not match on: %2f and %5c stay encoded, so /a%2fb and /a%5cb are one segment (matching the route /:id, not /a/:id).
Requests with malformed percent-encoding (such as /foo% or /%ZZ) have no canonical form to decode to and are rejected with a 400 Bad Request before any handler runs. Set the allowMalformedURL app option to receive the raw pathname instead.
#H3Event.res
Prepared HTTP response status and headers.
app.get("/", (event) => {
event.res.status = 200;
event.res.statusText = "OK";
event.res.headers.set("x-test", "works");
return "OK";
});