h3
An h3 v2 H3Event is { req, res, context }, so it goes to use() as it is. No adapter, no h3 peer dependency.
import { H3, HTTPError, serve } from "h3";
import { type WebSessionEvent, defineJWESession, deriveJWKFromPassword } from "unjwt/session";
const key = await deriveJWKFromPassword(process.env.SESSION_PASSWORD!, {
alg: "PBES2-HS256+A128KW",
salt: "my-app-session",
iterations: 600_000,
});
const session = defineJWESession<{ user: string }, "1h">({ key, maxAge: "1h", rolling: "15m" });
const useAppSession = (event: WebSessionEvent) => session.use(event);
const app = new H3()
.post("/login", async (event) => {
const body: { user?: unknown } = await event.req.json();
if (typeof body.user !== "string")
throw new HTTPError({ status: 400, message: "user required" });
const current = await useAppSession(event);
await current.update({ user: body.user });
return { id: current.id };
})
.get("/me", async (event) => {
const current = await useAppSession(event);
if (!current.id) throw new HTTPError({ status: 401 });
return current.data;
})
.post("/logout", async (event) => {
await (await useAppSession(event)).clear();
return { ok: true };
});
serve(app);The session state lives on event.context.sessions, so a middleware and a handler on the same event share one read.
#A handler that throws after a write
h3 sends a thrown handler's error with res.errHeaders only and discards the prepared res.headers. The session writes each cookie into both: when the event's res carries an errHeaders, every Set-Cookie line of the session (a write, a chunk, a drop) replaces that cookie's lines there too, and the other lines in errHeaders stay. A clear() followed by throw new HTTPError({ status: 401 }) therefore signs the client out, and an update() before a throw still sets the cookie:
app.post("/logout", async (event) => {
await (await useAppSession(event)).clear();
throw new HTTPError({ status: 401 }); // the 401 carries the cookie's Max-Age=0
});This holds for update(), rolling, clear() and the drop of an expired or unreadable cookie during the read. It is the one h3-specific step unjwt/session takes, and it only reads res.errHeaders, so any event shaped { req, res: { headers, errHeaders } } gets it.
#WebSockets
h3's defineWebSocketHandler runs on crossws 0.4: the upgrade hook receives a Request with an optional context, a thrown Response refuses, and the object it returns carries headers for the 101 handshake and context for every later hook's peer.context. The session is read once there; nothing can be written to the client after the upgrade.
import { defineWebSocketHandler } from "h3";
export default defineWebSocketHandler({
async upgrade(request) {
const headers = new Headers();
const session = await useAppSession({
headers: request.headers,
context: request.context,
res: { headers },
});
if (!session.id) throw new Response("Sign in first", { status: 401 });
return { headers, context: { ...session } }; // a roll or a drop made during the read rides on the 101
},
open(peer) {
peer.send(`hello ${(peer.context as { data: { user: string } }).data.user}`);
},
});The fields are named rather than spread because a spread drops a Request's headers getter. crossws 0.4 merges the returned headers with set, which keeps one Set-Cookie line: a session that fits one cookie is what rides the upgrade.