Skip to content

Commit

Permalink
feat(session): allow using with crossws hooks (#960)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Jan 22, 2025
1 parent a2338be commit 3f9e703
Show file tree
Hide file tree
Showing 2 changed files with 93 additions and 8 deletions.
53 changes: 53 additions & 0 deletions examples/ws-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
createApp,
defineEventHandler,
defineWebSocketHandler,
useSession,
type SessionConfig,
} from "h3";

export const app = createApp();

const sessionConfig: SessionConfig = {
password: "oee aaa ee o ee aa eeo ee aaa ee o ee",
};

app.use(
"/ws",
defineWebSocketHandler({
async upgrade(request) {
const session = await useSession(request, sessionConfig);
console.log(`[upgrade] Session id: ${session.id}`);
return {};
},
async open(peer) {
const session = await useSession(peer, sessionConfig);
console.log(`[open] Session id: ${session.id}`);
peer.send(`Hello, ${session.id}!`);
},
}),
);

app.use(
defineEventHandler(async (event) => {
// [IMPORTANT] Init the session before the first WebSocket upgrade
const session = await useSession(event, sessionConfig);

return /* html */ `
<div>Session id: ${session.id}</div>
<pre id="output"></pre>
<script type="module">
const log = (...args) => {
console.log(...args);
output.textContent += args.join(' ') + '\\n';
};
const output = document.getElementById('output');
const url = new URL('ws', location).href.replace(/^http/, 'ws')
const ws = new WebSocket(url);
ws.onopen = () => { log('[ws] Opened'); };
ws.onclose = () => { log('[ws] Closed'); };
ws.onmessage = (event) => { log('[ws] Message:', event.data); };
</script>
`;
}),
);
48 changes: 40 additions & 8 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { CookieSerializeOptions } from "cookie-es";
import crypto from "uncrypto";
import { seal, unseal, defaults as sealDefaults } from "iron-webcrypto";
import type { SealOptions } from "iron-webcrypto";
import type { H3Event } from "../event";
import { getCookie, setCookie } from "./cookie";
import { isEvent, type H3Event } from "../event";
import { parse as parseCookies } from "cookie-es";
import { setCookie } from "./cookie";

type SessionDataT = Record<string, any>;
export type SessionData<T extends SessionDataT = SessionDataT> = T;
Expand Down Expand Up @@ -41,11 +42,16 @@ const DEFAULT_COOKIE: SessionConfig["cookie"] = {
httpOnly: true,
};

// Compatible type with h3 v2 and external usage
type CompatEvent =
| { request: { headers: Headers }; context: any }
| { headers: Headers; context: any };

/**
* Create a session manager for the current request.
*/
export async function useSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
event: H3Event | CompatEvent,
config: SessionConfig,
) {
// Create a synced wrapper around the session
Expand All @@ -59,10 +65,16 @@ export async function useSession<T extends SessionDataT = SessionDataT>(
return (event.context.sessions?.[sessionName]?.data || {}) as T;
},
update: async (update: SessionUpdate<T>) => {
if (!isEvent(event)) {
throw new Error("[h3] Cannot update read-only session.");
}
await updateSession<T>(event, config, update);
return sessionManager;
},
clear: () => {
if (!isEvent(event)) {
throw new Error("[h3] Cannot clear read-only session.");
}
clearSession(event, config);
return Promise.resolve(sessionManager);
},
Expand All @@ -74,7 +86,7 @@ export async function useSession<T extends SessionDataT = SessionDataT>(
* Get the session for the current request.
*/
export async function getSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
event: H3Event | CompatEvent,
config: SessionConfig,
): Promise<Session<T>> {
const sessionName = config.name || DEFAULT_NAME;
Expand Down Expand Up @@ -105,14 +117,17 @@ export async function getSession<T extends SessionDataT = SessionDataT>(
typeof config.sessionHeader === "string"
? config.sessionHeader.toLowerCase()
: `x-${sessionName.toLowerCase()}-session`;
const headerValue = event.node.req.headers[headerName];
const headerValue = _getReqHeader(event, headerName);
if (typeof headerValue === "string") {
sealedSession = headerValue;
}
}
// Fallback to cookies
if (!sealedSession) {
sealedSession = getCookie(event, sessionName);
const cookieHeader = _getReqHeader(event, "cookie");
if (cookieHeader) {
sealedSession = parseCookies(cookieHeader + "")[sessionName];
}
}
if (sealedSession) {
// Unseal session data from cookie
Expand All @@ -129,6 +144,11 @@ export async function getSession<T extends SessionDataT = SessionDataT>(

// New session store in response cookies
if (!session.id) {
if (!isEvent(event)) {
throw new Error(
"Cannot initialize a new session. Make sure using `useSession(event)` in main handler.",
);
}
session.id =
config.generateId?.() ?? (config.crypto || crypto).randomUUID();
session.createdAt = Date.now();
Expand All @@ -138,6 +158,18 @@ export async function getSession<T extends SessionDataT = SessionDataT>(
return session;
}

function _getReqHeader(event: H3Event | CompatEvent, name: string) {
if ((event as H3Event).node) {
return (event as H3Event).node?.req.headers[name];
}
if ((event as { request?: Request }).request) {
return (event as { request?: Request }).request!.headers?.get(name);
}
if ((event as { headers?: Headers }).headers) {
return (event as { headers?: Headers }).headers!.get(name);
}
}

type SessionUpdate<T extends SessionDataT = SessionDataT> =
| Partial<SessionData<T>>
| ((oldData: SessionData<T>) => Partial<SessionData<T>> | undefined);
Expand Down Expand Up @@ -184,7 +216,7 @@ export async function updateSession<T extends SessionDataT = SessionDataT>(
* Encrypt and sign the session data for the current request.
*/
export async function sealSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
event: H3Event | CompatEvent,
config: SessionConfig,
) {
const sessionName = config.name || DEFAULT_NAME;
Expand All @@ -207,7 +239,7 @@ export async function sealSession<T extends SessionDataT = SessionDataT>(
* Decrypt and verify the session data for the current request.
*/
export async function unsealSession(
_event: H3Event,
_event: H3Event | CompatEvent,
config: SessionConfig,
sealed: string,
) {
Expand Down

0 comments on commit 3f9e703

Please sign in to comment.