Skip to content

Commit

Permalink
feat: session support (#315)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Feb 6, 2023
1 parent 9db177f commit 728dad0
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 13 deletions.
7 changes: 3 additions & 4 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
{
"extends": [
"eslint-config-unjs"
],
"extends": ["eslint-config-unjs"],
"rules": {
"unicorn/no-null": "off",
"unicorn/number-literal-case": "off"
"unicorn/number-literal-case": "off",
"@typescript-eslint/no-non-null-assertion": "off"
}
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ H3 has a concept of composable utilities that accept `event` (from `eventHandler
- `getResponseStatus(event)`
- `getResponseStatusText(event)`
- `readMultipartFormData(event)`
- `useSession(event, { password, name?, cookie?, seal?, crypto? })`
- `getSession(event, { password, name?, cookie?, seal?, crypto? })`
- `updateSession(event, { password, name?, cookie?, seal?, crypto? }), update)`
- `clearSession(event, { password, name?, cookie?, seal?, crypto? }))`
👉 You can learn more about usage in [JSDocs Documentation](https://www.jsdocs.io/package/h3#package-functions).
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
"dependencies": {
"cookie-es": "^0.5.0",
"destr": "^1.2.2",
"iron-webcrypto": "^0.2.7",
"radix3": "^1.0.0",
"ufo": "^1.0.1"
"ufo": "^1.0.1",
"uncrypto": "^0.1.2"
},
"devDependencies": {
"0x": "^5.4.1",
Expand Down
12 changes: 8 additions & 4 deletions playground/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
createRouter,
eventHandler,
toNodeListener,
parseCookies,
createError,
proxyRequest,
useSession,
} from "../src";

const app = createApp({ debug: true });
Expand All @@ -24,14 +24,18 @@ const router = createRouter()
"/error/:code",
eventHandler((event) => {
throw createError({
statusCode: Number.parseInt(event.context.params.code),
statusCode: Number.parseInt(event.context.params?.code || ""),
});
})
)
.get(
"/hello/:name",
eventHandler((event) => {
return `Hello ${parseCookies(event)}!`;
eventHandler(async (event) => {
const password = "secretsecretsecretsecretsecretsecretsecret";
const session = await useSession<{ ctr: number }>(event, { password });
await session.update((data) => ({ ctr: Number(data.ctr || 0) + 2 }));
await session.update({ ctr: Number(session.data.ctr || 0) - 1 });
return `Hello ${event.context.params?.name}! (you visited this page ${session.data.ctr} times. session id: ${session.id})`;
})
);

Expand Down
23 changes: 21 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { H3Event } from "./event";
import { Session } from "./utils/session";

// https://www.rfc-editor.org/rfc/rfc7231#section-4.1
export type HTTPMethod =
Expand All @@ -25,8 +26,12 @@ export type Encoding =
| "binary"
| "hex";

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface H3EventContext extends Record<string, any> {}
export interface H3EventContext extends Record<string, any> {
/* Matched router parameters */
params?: Record<string, string>;
/* Cached session data */
sessions?: Record<string, Session>;
}

export type EventHandlerResponse<T = any> = T | Promise<T>;

Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./cookie";
export * from "./proxy";
export * from "./request";
export * from "./response";
export * from "./session";
137 changes: 137 additions & 0 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { seal, unseal, defaults as sealDefaults } from "iron-webcrypto";
import type { SealOptions } from "iron-webcrypto";
import type { CookieSerializeOptions } from "cookie-es";
import crypto from "uncrypto";
import type { H3Event } from "../event";
import { getCookie, setCookie } from "./cookie";

type SessionDataT = Record<string, string | number | boolean>;
export type SessionData<T extends SessionDataT = SessionDataT> = T;

export interface Session<T extends SessionDataT = SessionDataT> {
id: string;
data: SessionData<T>;
}

export interface SessionConfig {
password: string;
name?: string;
cookie?: CookieSerializeOptions;
seal?: SealOptions;
crypto?: Crypto;
}

const DEFAULT_NAME = "h3";
const DEFAULT_COOKIE: SessionConfig["cookie"] = {
path: "/",
secure: true,
httpOnly: true,
};

export async function useSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
config: SessionConfig
) {
// Create a synced wrapper around the session
const sessionName = config.name || DEFAULT_NAME;
await getSession(event, config); // Force init
const sessionManager = {
get id() {
return event.context.sessions?.[sessionName]?.id;
},
get data() {
return event.context.sessions?.[sessionName]?.data || {};
},
update: async (update: SessionUpdate<T>) => {
await updateSession<T>(event, config, update);
return sessionManager;
},
clear: async () => {
await clearSession(event, config);
return sessionManager;
},
};
return sessionManager;
}

export async function getSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
config: SessionConfig
): Promise<Session<T>> {
const sessionName = config.name || DEFAULT_NAME;

// Return existing session if available
if (!event.context.sessions) {
event.context.sessions = Object.create(null);
}
if (event.context.sessions![sessionName]) {
return event.context.sessions![sessionName] as Session<T>;
}

// Prepare an empty session object and store in context
const session: Session<T> = { id: "", data: Object.create(null) };
event.context.sessions![sessionName] = session;

// Try to hydrate from cookies
const reqCookie = getCookie(event, sessionName);
if (!reqCookie) {
// New session store in response cookies
session.id = (config.crypto || crypto).randomUUID();
await updateSession(event, config);
} else {
// Unseal session data from cookie
const unsealed = await unseal(
config.crypto || crypto,
reqCookie,
config.password,
config.seal || sealDefaults
);
Object.assign(session, unsealed);
}

return session;
}

type SessionUpdate<T extends SessionDataT = SessionDataT> =
| Partial<SessionData<T>>
| ((oldData: SessionData<T>) => Partial<SessionData<T>> | undefined);

export async function updateSession<T extends SessionDataT = SessionDataT>(
event: H3Event,
config: SessionConfig,
update?: SessionUpdate<T>
): Promise<Session<T>> {
const sessionName = config.name || DEFAULT_NAME;

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

// Update session data if provided
if (typeof update === "function") {
update = update(session.data);
}
if (update) {
Object.assign(session.data, update);
}

// Seal and store in cookie
const sealed = await seal(
config.crypto || crypto,
session,
config.password,
config.seal || sealDefaults
);
setCookie(event, sessionName, sealed, config.cookie || DEFAULT_COOKIE);

return session;
}

export async function clearSession(event: H3Event, config: SessionConfig) {
const sessionName = config.name || DEFAULT_NAME;
if (event.context.sessions?.[sessionName]) {
delete event.context.sessions![sessionName];
}
await setCookie(event, sessionName, "", config.cookie || DEFAULT_COOKIE);
}

0 comments on commit 728dad0

Please sign in to comment.