diff --git a/README.md b/README.md index 378ce4a..d6ebfa6 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,11 @@ 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, + // Forces unmodified session to be saved to the store. Setting `false` is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie + saveUninitialized: true, + // Sessions are saved to the store, even if they were never modified during the request. Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, and changes made to the session in one request may get overwritten when the other request ends + resave: 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..b41ad46 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,7 +26,9 @@ const defaults: FilledModuleOptions = { }, domain: false, ipPinning: false as boolean|SessionIpPinningOptions, - rolling: false + rolling: false, + saveUninitialized: true, + resave: true }, api: { isEnabled: true, diff --git a/src/runtime/server/middleware/session/index.ts b/src/runtime/server/middleware/session/index.ts index 6ee76e1..98de9d1 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 equal from 'fast-deep-equal' import { SameSiteOptions, Session, SessionOptions } 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' @@ -66,25 +68,24 @@ export const deleteSession = async (event: H3Event) => { } const newSession = async (event: H3Event) => { - const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session as SessionOptions + const sessionOptions = useRuntimeConfig().session.session as SessionOptions const now = new Date() - // (Re-)Set cookie - const sessionId = nanoid(sessionOptions.idLength) - safeSetCookie(event, SESSION_COOKIE_NAME, sessionId, now) - - // Store session data in storage const session: Session = { - id: sessionId, + id: nanoid(sessionOptions.idLength), createdAt: now, ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined } - await setStorageSession(sessionId, session) return session } +const setSession = async (session: Session, event: H3Event) => { + safeSetCookie(event, SESSION_COOKIE_NAME, session.id, session.createdAt) + await setStorageSession(session.id, session) + return session +} + const getSession = async (event: H3Event): Promise => { // 1. Does the sessionId cookie exist on the request? const existingSessionId = getCurrentSessionId(event) @@ -98,8 +99,7 @@ const getSession = async (event: H3Event): Promise => { return null } - const runtimeConfig = useRuntimeConfig() - const sessionOptions = runtimeConfig.session.session as SessionOptions + const sessionOptions = useRuntimeConfig().session.session as SessionOptions const sessionExpiryInSeconds = sessionOptions.expiryInSeconds try { @@ -143,21 +143,29 @@ const ensureSession = async (event: H3Event) => { event.context.sessionId = session.id event.context.session = session - return session + + return { ...session } } export default eventHandler(async (event: H3Event) => { + const sessionOptions = useRuntimeConfig().session.session as SessionOptions + // 1. Ensure that a session is present by either loading or creating one - await ensureSession(event) + const session = await ensureSession(event) // 2. Setup a hook that saves any changed made to the session by the subsequent endpoints & middlewares - event.res.on('finish', async () => { - // Session id may not exist if session was deleted - const session = await getSession(event) - if (!session) { - return + resEndProxy(event.node.res, async () => { + const contextSession = event.context.session as Session + const storedSession = await getSession(event) + + if (!storedSession) { + // If there isn't a session in the storage yet, save a new session if saveUninitialized is true, or if the session in the event context has been modified + if (sessionOptions.saveUninitialized || !equal(contextSession, session)) { + await setSession(contextSession, event) + } + // Update the session in the storage if resave is true, or if the session in the event context has been modified + } else if (sessionOptions.resave || !equal(contextSession, storedSession)) { + await setStorageSession(storedSession.id, contextSession) } - - await setStorageSession(session.id, event.context.session) }) }) diff --git a/src/runtime/server/middleware/session/resEndProxy.ts b/src/runtime/server/middleware/session/resEndProxy.ts new file mode 100644 index 0000000..1007b41 --- /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..4139877 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,23 @@ export interface SessionOptions { * @type 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 + /** + * Forces the session to be saved back to the session store, even if the session was never modified during the request. + * Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server, + * and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using). + * @default true + * @example false + * @type boolean + */ + resave: boolean } export interface ApiOptions {