diff --git a/README.md b/README.md index 5c6c5af..f2e74d7 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,9 @@ Here's what the full _default_ module configuration looks like: // Sessions aren't pinned to the user's IP address ipPinning: false, // Expiration of the sessions are not reset to the original expiryInSeconds on every request - rolling: false + rolling: false, + // Uninitialized, resp. unmodified sessions are saved to the store, so session cookies are set at the first response + saveUninitialized: true }, api: { // The API is enabled diff --git a/package-lock.json b/package-lock.json index 504ac59..8e9bf87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "argon2": "^0.30.2", "dayjs": "^1.11.6", "defu": "^6.1.0", + "fast-deep-equal": "^3.1.3", "h3": "^1.0.1", "unstorage": "^1.0.1" }, @@ -4461,8 +4462,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.12", @@ -13218,8 +13218,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.12", diff --git a/package.json b/package.json index 88e65d1..e2333df 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "argon2": "^0.30.2", "dayjs": "^1.11.6", "defu": "^6.1.0", + "fast-deep-equal": "^3.1.3", "h3": "^1.0.1", "unstorage": "^1.0.1" }, diff --git a/src/module.ts b/src/module.ts index d7d0c80..eedf5de 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,7 +26,8 @@ const defaults: FilledModuleOptions = { }, domain: false, ipPinning: false as boolean|SessionIpPinningOptions, - rolling: false + rolling: false, + saveUninitialized: true }, api: { isEnabled: true, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 6ee76e1..557debb 100644 --- a/src/runtime/server/middleware/session/index.ts +++ b/src/runtime/server/middleware/session/index.ts @@ -1,10 +1,12 @@ import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3' import { nanoid } from 'nanoid' import dayjs from 'dayjs' -import { SameSiteOptions, Session, SessionOptions } from '../../../../types' +import equal from 'fast-deep-equal' +import { SameSiteOptions, Session, SessionOptions, SessionContent } from '../../../../types' import { dropStorageSession, getStorageSession, setStorageSession } from './storage' import { processSessionIp, getHashedIpAddress } from './ipPinning' import { SessionExpired } from './exceptions' +import { resEndProxy } from './resEndProxy' import { useRuntimeConfig } from '#imports' const SESSION_COOKIE_NAME = 'sessionId' @@ -65,7 +67,7 @@ export const deleteSession = async (event: H3Event) => { deleteCookie(event, SESSION_COOKIE_NAME) } -const newSession = async (event: H3Event) => { +const newSession = async (event: H3Event, sessionContent?: SessionContent) => { const runtimeConfig = useRuntimeConfig() const sessionOptions = runtimeConfig.session.session as SessionOptions const now = new Date() @@ -80,11 +82,23 @@ const newSession = async (event: H3Event) => { createdAt: now, ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } + if (sessionContent) { + Object.assign(session, sessionContent) + } await setStorageSession(sessionId, session) return session } +const newSessionIfModified = (event: H3Event, sessionContent: SessionContent) => { + const source = { ...sessionContent } + resEndProxy(event.res, async () => { + if (!equal(sessionContent, source)) { + await newSession(event, sessionContent) + } + }) +} + const getSession = async (event: H3Event): Promise => { // 1. Does the sessionId cookie exist on the request? const existingSessionId = getCurrentSessionId(event) @@ -136,7 +150,15 @@ const ensureSession = async (event: H3Event) => { let session = await getSession(event) if (!session) { - session = await newSession(event) + if (sessionOptions.saveUninitialized) { + session = await newSession(event) + } else { + // 1. Create an empty session object in the event context + event.context.session = {} + // 2. Create a new session if the object has been modified by any event handler + newSessionIfModified(event, event.context.session) + return null + } } else if (sessionOptions.rolling) { session = updateSessionExpirationDate(session, event) } diff --git a/src/runtime/server/middleware/session/resEndProxy.ts b/src/runtime/server/middleware/session/resEndProxy.ts new file mode 100644 index 0000000..e930f92 --- /dev/null +++ b/src/runtime/server/middleware/session/resEndProxy.ts @@ -0,0 +1,14 @@ +import type { ServerResponse } from 'node:http' + +type MiddleWare = () => Promise + +// Proxy res.end() to get a callback at the end of all event handlers +export const resEndProxy = (res: ServerResponse, middleWare: MiddleWare) => { + const _end = res.end + + // @ts-ignore Replacing res.end() will lead to type checking error + res.end = async (chunk: any, encoding: BufferEncoding) => { + await middleWare() + return _end.call(res, chunk, encoding) as ServerResponse + } +} diff --git a/src/types.ts b/src/types.ts index af9b8ce..0756310 100644 --- a/src/types.ts +++ b/src/types.ts @@ -104,7 +104,15 @@ export interface SessionOptions { * @example true * @type boolean */ - rolling: boolean + rolling: boolean, + /** + * Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. + * Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. + * @default true + * @example false + * @type boolean + */ + saveUninitialized: boolean } export interface ApiOptions { @@ -189,3 +197,7 @@ export declare interface Session { [key: string]: any; } + +export declare interface SessionContent { + [key: string]: any; +}