Nitro
Nitro 3 runs on h3 v2 and hands handlers the same H3Event, which goes to use() as it is. Nitro has no auto-imports, and its own h3 is not your dependency: everything comes from nitro and nitro/h3.
// server/utils/session.ts
import { HTTPError } from "nitro";
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 definition = defineJWESession<{ user: string }, "1h">({ key, maxAge: "1h", rolling: "15m" });
export const useAppSession = (event: WebSessionEvent) => definition.use(event);
export async function requireAppSession(event: WebSessionEvent) {
const current = await useAppSession(event);
if (!current.id) throw new HTTPError({ status: 401, message: "Sign in first" });
return current;
}// server/routes/login.post.ts
import { HTTPError, defineHandler } from "nitro";
import { readBody } from "nitro/h3";
import { useAppSession } from "../utils/session.ts";
export default defineHandler(async (event) => {
const body: { user?: unknown } | undefined = await readBody(event);
if (typeof body?.user !== "string")
throw new HTTPError({ status: 400, message: "user required" });
const session = await useAppSession(event);
await session.update({ user: body.user });
return { id: session.id };
});defineHandler, defineWebSocketHandler and HTTPError come from nitro, readBody and the other h3 helpers from nitro/h3, and the session utils are imported by path.
#A handler that throws after a write
Nitro's error handler answers with a Response of its own, which h3 sends without the handler's prepared headers. A clear() followed by a thrown 401 therefore leaves the client signed in, and the errHeaders route that works on bare h3 makes no difference here. The next request that opens the session drops the cookie.
#WebSockets
The upgrade hook receives a Request with an optional context; a thrown Response refuses; the returned object carries headers for the 101 handshake and context for every later hook's peer.context. The session is read once there, and nothing can be written to the client after the upgrade.
// server/routes/ws.ts
import { defineWebSocketHandler } from "nitro";
import { useAppSession } from "../utils/session.ts";
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.
The playgrounds/nitro directory of the repository is this page as a running app, with a static index.html in front.