Skip to content
27 changes: 26 additions & 1 deletion docs/4.examples/handle-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ h3 provides many utilities to handle sessions:
- `getSession` initializes or retrieves the current user session.
- `updateSession` updates the data of the current session.
- `clearSession` clears the current session.
- `rotateSession` rotates the session ID and expiration.

Most of the time, you will use `useSession` to manipulate the session.

Expand Down Expand Up @@ -37,7 +38,7 @@ This will initialize a session and return an header `Set-Cookie` with a cookie n
If the request contains a cookie named `h3` or a header named `x-h3-session`, the session will be initialized with the content of the cookie or the header.

> [!NOTE]
> The header take precedence over the cookie.
> The header takes precedence over the cookie.

## Get Data from a Session

Expand Down Expand Up @@ -86,6 +87,8 @@ We try to get a session from the request. If there is no session, a new one will

Try to visit the page multiple times and you will see the number of times you visited the page.

If a `maxAge` is configured, the expiration date of the session will not be updated with a call to `update` on the session object, nor with a call using the `updateSession` utility. For details on how to extend the session's expiration date, see the [Rotating a Session section](#rotating-a-session).

> [!NOTE]
> If you use a CLI tool like `curl` to test this example, you will not see the number of times you visited the page because the CLI tool does not save cookies. You must get the cookie from the response and send it back to the server.

Expand All @@ -109,6 +112,28 @@ app.use("/clear", async (event) => {

h3 will send a header `Set-Cookie` with an empty cookie named `h3` to clear the session.

## Rotating a Session

Session rotation is a way to reset the expiration date of the session and assign a new ID to the same session data. This effectively allows a session to be extended when a `maxAge` is configured.

To rotate a session, we will still use `useSession`. Under the hood, it will use `rotateSession` to rotate the session.

```js
import { useSession } from "h3";

app.use("/rotate", async (event) => {
const session = await useSession(event, {
password: "80d42cfb-1cd2-462c-8f17-e3237d9027e9",
});

await session.rotate();

return "Session rotated";
});
```

h3 will send a header `Set-Cookie` with a new cookie that includes the new ID and expiration date but the same session data.

## Options

When to use `useSession`, you can pass an object with options as the second argument to configure the session:
Expand Down
96 changes: 81 additions & 15 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ import {
} from "./internal/session";
import { EmptyObject } from "./internal/obj";

/**
* Get the session name from the config.
*/
function getSessionName(config: SessionConfig) {
return config.name || DEFAULT_SESSION_NAME;
}

/**
* Generate the session id from the config.
*/
function generateId(config: SessionConfig) {
return config.generateId?.() ?? (config.crypto || crypto).randomUUID();
}

/**
* Get the max age TTL in ms from the config.
*/
function getMaxAgeTTL(config: SessionConfig) {
return config.maxAge ? config.maxAge * 1000 : 0;
}

/**
* Create a session manager for the current request.
*
Expand All @@ -17,7 +38,7 @@ export async function useSession<T extends SessionData = SessionData>(
config: SessionConfig,
) {
// Create a synced wrapper around the session
const sessionName = config.name || DEFAULT_SESSION_NAME;
const sessionName = getSessionName(config);
await getSession(event, config); // Force init
const sessionManager = {
get id() {
Expand All @@ -34,6 +55,10 @@ export async function useSession<T extends SessionData = SessionData>(
clearSession(event, config);
return Promise.resolve(sessionManager);
},
rotate: async () => {
await rotateSession<T>(event, config);
return sessionManager;
},
};
return sessionManager;
}
Expand All @@ -45,7 +70,7 @@ export async function getSession<T extends SessionData = SessionData>(
event: H3Event,
config: SessionConfig,
): Promise<Session<T>> {
const sessionName = config.name || DEFAULT_SESSION_NAME;
const sessionName = getSessionName(config);

// Return existing session if available
if (!event.context.sessions) {
Expand Down Expand Up @@ -97,8 +122,7 @@ export async function getSession<T extends SessionData = SessionData>(

// New session store in response cookies
if (!session.id) {
session.id =
config.generateId?.() ?? (config.crypto || crypto).randomUUID();
session.id = generateId(config);
session.createdAt = Date.now();
await updateSession(event, config);
}
Expand All @@ -118,12 +142,12 @@ export async function updateSession<T extends SessionData = SessionData>(
config: SessionConfig,
update?: SessionUpdate<T>,
): Promise<Session<T>> {
const sessionName = config.name || DEFAULT_SESSION_NAME;
const sessionName = getSessionName(config);

// Access current session
const session: Session<T> =
(event.context.sessions?.[sessionName] as Session<T>) ||
(await getSession<T>(event, config));
(await getSession(event, config));

// Update session data if provided
if (typeof update === "function") {
Expand All @@ -139,7 +163,40 @@ export async function updateSession<T extends SessionData = SessionData>(
setCookie(event, sessionName, sealed, {
...DEFAULT_SESSION_COOKIE,
expires: config.maxAge
? new Date(session.createdAt + config.maxAge * 1000)
? new Date(session.createdAt + getMaxAgeTTL(config))
: undefined,
...config.cookie,
});
}

return session;
}

/**
* Rotate the session id and createdAt timestamp.
*/
export async function rotateSession<T extends SessionData = SessionData>(
event: H3Event,
config: SessionConfig,
): Promise<Session<T>> {
const sessionName = getSessionName(config);

// Access current session
const session: Session<T> =
(event.context.sessions?.[sessionName] as Session<T>) ||
(await getSession(event, config));

// Rotate session id and createdAt timestamp
session.id = generateId(config);
session.createdAt = Date.now();

// Seal and store in cookie
if (config.cookie !== false) {
const sealed = await sealSession(event, config);
setCookie(event, sessionName, sealed, {
...DEFAULT_SESSION_COOKIE,
expires: config.maxAge
? new Date(session.createdAt + getMaxAgeTTL(config))
: undefined,
...config.cookie,
});
Expand All @@ -155,7 +212,7 @@ export async function sealSession<T extends SessionData = SessionData>(
event: H3Event,
config: SessionConfig,
) {
const sessionName = config.name || DEFAULT_SESSION_NAME;
const sessionName = getSessionName(config);

// Access current session
const session: Session<T> =
Expand All @@ -164,7 +221,7 @@ export async function sealSession<T extends SessionData = SessionData>(

const sealed = await seal(session, config.password, {
...sealDefaults,
ttl: config.maxAge ? config.maxAge * 1000 : 0,
ttl: getMaxAgeTTL(config),
...config.seal,
});

Expand All @@ -181,12 +238,12 @@ export async function unsealSession(
) {
const unsealed = (await unseal(sealed, config.password, {
...sealDefaults,
ttl: config.maxAge ? config.maxAge * 1000 : 0,
ttl: getMaxAgeTTL(config),
...config.seal,
})) as Partial<Session>;
if (config.maxAge) {
const age = Date.now() - (unsealed.createdAt || Number.NEGATIVE_INFINITY);
if (age > config.maxAge * 1000) {
if (age > getMaxAgeTTL(config)) {
throw new Error("Session expired!");
}
}
Expand All @@ -198,12 +255,21 @@ export async function unsealSession(
*/
export function clearSession(
event: H3Event,
config: Partial<SessionConfig>,
config: SessionConfig,
): Promise<void> {
const sessionName = config.name || DEFAULT_SESSION_NAME;
if (event.context.sessions?.[sessionName]) {
delete event.context.sessions![sessionName];
const sessionName = getSessionName(config);
if (!event.context.sessions) {
event.context.sessions = new EmptyObject();
}

// Clobber the original session with a new session
event.context.sessions![sessionName] = {
id: generateId(config),
createdAt: Date.now(),
data: new EmptyObject(),
} satisfies Session;

// Set a cleared session cookie
setCookie(event, sessionName, "", {
...DEFAULT_SESSION_COOKIE,
...config.cookie,
Expand Down