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.