Skip to content

Commit

Permalink
Make implementation cleaner (#348)
Browse files Browse the repository at this point in the history
  • Loading branch information
hoangvvo authored Sep 20, 2021
1 parent acb8319 commit 6816ae9
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 150 deletions.
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,25 +176,25 @@ await applySession(req, res, options);

`next-session` accepts the properties below.

| options | description | default |
| --------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| name | The name of the cookie to be read from the request and set to the response. | `sid` |
| store | The session store instance to be used. | `MemoryStore` |
| genid | The function that generates a string for a new session ID. | [`nanoid`](https://github.com/ai/nanoid) |
| encode | Transforms session ID before setting cookie. It takes the raw session ID and returns the decoded/decrypted session ID. | undefined |
| decode | Transforms session ID back while getting from cookie. It should return the encoded/encrypted session ID | undefined |
| touchAfter | Only touch after an amount of time. Disabled by default or if set to `-1`. See [touchAfter](#touchAfter). | `-1` (Disabled) |
| autoCommit | Automatically commit session. Disable this if you want to manually `session.commit()` | `true` |
| cookie.secure | Specifies the boolean value for the **Secure** `Set-Cookie` attribute. | `false` |
| cookie.httpOnly | Specifies the boolean value for the **httpOnly** `Set-Cookie` attribute. | `true` |
| cookie.path | Specifies the value for the **Path** `Set-Cookie` attribute. | `/` |
| cookie.domain | Specifies the value for the **Domain** `Set-Cookie` attribute. | unset |
| cookie.sameSite | Specifies the value for the **SameSite** `Set-Cookie` attribute. | unset |
| cookie.maxAge | **(in seconds)** Specifies the value for the **Max-Age** `Set-Cookie` attribute. | unset (Browser session) |
| options | description | default |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------- |
| name | The name of the cookie to be read from the request and set to the response. | `sid` |
| store | The session store instance to be used. | `MemoryStore` |
| genid | The function that generates a string for a new session ID. | [`nanoid`](https://github.com/ai/nanoid) |
| encode | Transforms session ID before setting cookie. It takes the raw session ID and returns the decoded/decrypted session ID. | undefined |
| decode | Transforms session ID back while getting from cookie. It should return the encoded/encrypted session ID | undefined |
| touchAfter | Only touch after an amount of time **(in miliseconds)** since last access. Disabled by default or if set to `-1`. See [touchAfter](#touchAfter). | `-1` (Disabled) |
| autoCommit | Automatically commit session. Disable this if you want to manually `session.commit()` | `true` |
| cookie.secure | Specifies the boolean value for the **Secure** `Set-Cookie` attribute. | `false` |
| cookie.httpOnly | Specifies the boolean value for the **httpOnly** `Set-Cookie` attribute. | `true` |
| cookie.path | Specifies the value for the **Path** `Set-Cookie` attribute. | `/` |
| cookie.domain | Specifies the value for the **Domain** `Set-Cookie` attribute. | unset |
| cookie.sameSite | Specifies the value for the **SameSite** `Set-Cookie` attribute. | unset |
| cookie.maxAge | **(in seconds)** Specifies the value for the **Max-Age** `Set-Cookie` attribute. | unset (Browser session) |

### touchAfter

Touching refers to the extension of session lifetime, both in browser (by modifying `Expires` attribute in [Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header) and session store (using its respective method). This prevents the session from being expired after a while.
Touching refers to the extension of session lifetime, both in browser (by modifying `Expires` attribute in [Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header) and session store (using its respective method) upon access. This prevents the session from being expired after a while.

In `autoCommit` mode (which is enabled by default), for optimization, a session is only touched, not saved, if it is not modified. The value of `touchAfter` allows you to skip touching if the session is still recent, thus, decreasing database load.

Expand Down
218 changes: 113 additions & 105 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,35 @@ import { nanoid } from 'nanoid';
import MemoryStore from './store/memory';
import { Options, Session, SessionData, SessionStore } from './types';

const stringify = (sess: SessionData | null | undefined) =>
const hash = (sess: SessionData) =>
JSON.stringify(sess, (key, val) => (key === 'cookie' ? undefined : val));

const commitHead = (
res: ServerResponse,
name: string,
session: SessionData | null | undefined,
touched: boolean,
{ cookie, id }: SessionData,
encodeFn?: Options['encode']
) => {
if (res.headersSent || !session) return;
if (session.isNew || touched) {
const cookieArr = [
serialize(name, encodeFn ? encodeFn(session.id) : session.id, {
path: session.cookie.path,
httpOnly: session.cookie.httpOnly,
expires: session.cookie.expires,
domain: session.cookie.domain,
sameSite: session.cookie.sameSite,
secure: session.cookie.secure,
}),
];
const prevCookies = res.getHeader('set-cookie');
if (prevCookies) {
if (Array.isArray(prevCookies)) cookieArr.push(...prevCookies);
else cookieArr.push(prevCookies as string);
}
res.setHeader('set-cookie', cookieArr);
if (res.headersSent) return;
const cookieArr = [
serialize(name, encodeFn ? encodeFn(id) : id, {
path: cookie.path,
httpOnly: cookie.httpOnly,
expires: cookie.expires,
domain: cookie.domain,
sameSite: cookie.sameSite,
secure: cookie.secure,
}),
];
const prevCookies = res.getHeader('set-cookie');
if (prevCookies) {
if (Array.isArray(prevCookies)) cookieArr.push(...prevCookies);
else cookieArr.push(prevCookies as string);
}
res.setHeader('set-cookie', cookieArr);
};

const prepareSession = (session: SessionData) => {
const obj: SessionData = {} as any;
for (const key in session)
!(key === ('isNew' || key === 'id')) && (obj[key] = session[key]);
return obj;
};

const compatLayer = {
const storeFn = {
destroy(s: ExpressStore | SessionStore, sid: string) {
return new Promise<void>((resolve, reject) => {
const result = s.destroy(sid, (err) => (err ? reject(err) : resolve()));
Expand Down Expand Up @@ -80,133 +70,151 @@ const compatLayer = {
},
};

const save = async (
store: SessionStore | ExpressStore,
session: SessionData | null | undefined
) => session && compatLayer.set(store, session.id, prepareSession(session));

let memoryStore: MemoryStore;

export async function applySession<T = {}>(
req: IncomingMessage & { session?: Session | null | undefined },
req: IncomingMessage & { session?: Session },
res: ServerResponse,
options: Options = {}
): Promise<void> {
if (req.session) return;

/**
* Options init
*/
const name = options.name || 'sid';
const store =
options.store || (memoryStore = memoryStore || new MemoryStore());

// Compat
(req as any).sessionStore = store;
const genId = options.genid || nanoid;
const encode = options.encode;
const decode = options.decode;
let touchAfter = -1;
// compat: if rolling is `true`, user might have wanted to touch every time
// thus defaulting options.touchAfter to 0 instead of -1
if (options.rolling && !('touchAfter' in options)) {
console.warn(
'The use of options.rolling is deprecated. Setting this to `true` without options.touchAfter causes options.touchAfter to be defaulted to `0` (always)'
);
options.touchAfter = 0;
touchAfter = 0;
}
if (typeof options.touchAfter === 'number') {
touchAfter = options.touchAfter;
}
const autoCommit =
typeof options.autoCommit === 'boolean' ? options.autoCommit : true;
const cookieOpts = options?.cookie || {};

const name = options.name || 'sid';
/**
* Main
*/

const commit = async () => {
commitHead(res, name, req.session, shouldTouch, options.encode);
await save(store, req.session);
};
let isDestroyed = false;
let isTouched = false;

const destroy = async () => {
await compatLayer.destroy(store, req.session!.id);
req.session = null;
const _now = Date.now();
const resetMaxAge = (session: Session) => {
isTouched = true;
session.cookie.expires = new Date(_now + session.cookie.maxAge! * 1000);
};

let sessId =
req.headers && req.headers.cookie ? parse(req.headers.cookie)[name] : null;
if (sessId && options.decode) sessId = options.decode(sessId);
if (sessId && decode) sessId = decode(sessId);

const loadedSess = sessId ? await storeFn.get(store, sessId) : null;

// @ts-ignore: req.session as this point is not of type Session
// but SessionData, but the missing keys will be added later
req.session = sessId ? await compatLayer.get(store, sessId) : null;
let session: Session;

if (req.session) {
req.session.commit = commit;
req.session.destroy = destroy;
req.session.isNew = false;
req.session.id = sessId!;
if (loadedSess) {
session = loadedSess as Session;
// Some store return cookie.expires as string, convert it to Date
if (typeof req.session.cookie.expires === 'string') {
req.session.cookie.expires = new Date(req.session.cookie.expires);
if (typeof session.cookie.expires === 'string') {
session.cookie.expires = new Date(session.cookie.expires);
}
} else {
req.session = {
sessId = genId();
session = {
cookie: {
path: options.cookie?.path || '/',
httpOnly: options.cookie?.httpOnly || true,
domain: options.cookie?.domain || undefined,
sameSite: options.cookie?.sameSite,
secure: options.cookie?.secure || false,
...(options.cookie?.maxAge
? { maxAge: options.cookie.maxAge, expires: new Date() }
path: cookieOpts.path || '/',
httpOnly: cookieOpts.httpOnly || true,
domain: cookieOpts.domain || undefined,
sameSite: cookieOpts.sameSite,
secure: cookieOpts.secure || false,
...(cookieOpts.maxAge
? {
maxAge: cookieOpts.maxAge,
expires: new Date(_now + cookieOpts.maxAge * 1000),
}
: { maxAge: undefined }),
},
commit,
destroy,
isNew: true,
id: (options.genid || nanoid)(),
};
} as Session;
}

Object.defineProperties(session, {
commit: {
value: async function commit(this: Session) {
commitHead(res, name, this, encode);
await storeFn.set(store, this.id, this);
},
},
destroy: {
value: async function destroy(this: Session) {
isDestroyed = true;
this.cookie.expires = new Date(1);
await storeFn.destroy(store, this.id);
delete req.session;
},
},
isNew: { value: !loadedSess },
id: { value: sessId as string },
});

// Set to req.session
req.session = session;
// Compat with express-session
(req as any).sessionStore = store;

// prevSessStr is used to compare the session later
// for touchability -- that is, we only touch the
// session if it has changed. This check is used
// in autoCommit mode only
const prevSessStr: string | undefined =
options.autoCommit !== false
? req.session.isNew
? '{}'
: stringify(req.session)
: undefined;

let shouldTouch = false;

if (req.session.cookie.maxAge) {
if (
// Extend expires either if it is a new session
req.session.isNew ||
// or if touchAfter condition is satsified
(typeof options.touchAfter === 'number' &&
options.touchAfter !== -1 &&
(shouldTouch =
req.session.cookie.maxAge * 1000 -
(req.session.cookie.expires.getTime() - Date.now()) >=
options.touchAfter))
) {
req.session.cookie.expires = new Date(
Date.now() + req.session.cookie.maxAge * 1000
);
let prevHash: string | undefined;
if (autoCommit) {
prevHash = hash(session);
}

// Extends the expiry of the session
// if touchAfter is applicable
if (touchAfter >= 0 && session.cookie.expires) {
const lastTouchedTime =
session.cookie.expires.getTime() - session.cookie.maxAge * 1000;
if (_now - lastTouchedTime >= touchAfter) {
resetMaxAge(session);
}
}

// autocommit: We commit the header and save the session automatically
// by "proxying" res.writeHead and res.end methods. After committing, we
// call the original res.writeHead and res.end.
if (options.autoCommit !== false) {

if (autoCommit) {
const oldWritehead = res.writeHead;
res.writeHead = function resWriteHeadProxy(...args: any) {
commitHead(res, name, req.session, shouldTouch, options.encode);
if (session.isNew || isTouched || isDestroyed) {
commitHead(res, name, session, encode);
}
return oldWritehead.apply(this, args);
};
const oldEnd = res.end;
res.end = function resEndProxy(...args: any) {
const onSuccess = () => oldEnd.apply(this, args);
if (stringify(req.session) !== prevSessStr) {
save(store, req.session).finally(onSuccess);
} else if (req.session && shouldTouch && store.touch) {
compatLayer
.touch(store, req.session!.id, prepareSession(req.session!))
.finally(onSuccess);
const done = () => oldEnd.apply(this, args);
if (isDestroyed) return done();
if (hash(session) !== prevHash) {
storeFn.set(store, session.id, session).finally(done);
} else if (isTouched && store.touch) {
storeFn.touch(store, session.id, session).finally(done);
} else {
onSuccess();
done();
}
};
}
Expand Down
39 changes: 10 additions & 29 deletions test/unit/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @ts-nocheck
import { parse as parseCookie } from 'cookie';
import signature from 'cookie-signature';
import EventEmitter from 'events';
import { Store as ExpressStore } from 'express-session';
import { createServer, IncomingMessage, RequestListener } from 'http';
Expand All @@ -15,38 +17,17 @@ import {
} from '../../src';
import MemoryStore from '../../src/store/memory';
import { Options } from '../../src/types';
const signature = require('cookie-signature');
const { parse: parseCookie } = require('cookie');

class CbStore {
sessions: Record<string, any> = {};
constructor() {}

/* eslint-disable no-unused-expressions */
get(sid: string, cb: (err?: any, session?: SessionData | null) => void) {
cb && cb(null, this.sessions[sid]);
}

set(sid: string, sess: SessionData, cb: (err?: any) => void) {
this.sessions[sid] = sess;
cb && cb();
}

destroy(sid: string, cb: (err?: any) => void) {
delete this.sessions[sid];
cb();
}

touch(sid: string, sess: SessionData, cb: (err: any) => void) {
cb && cb(null);
}
}
const CbStore = expressSession.MemoryStore;

const defaultHandler: RequestListener = async (req, res) => {
if (req.method === 'POST')
if (req.method === 'POST') {
req.session.views = req.session.views ? req.session.views + 1 : 1;
if (req.method === 'DELETE') await req.session.destroy();
res.end(`${(req.session && req.session.views) || 0}`);
}
if (req.method === 'DELETE') {
await req.session.destroy();
}
res.end(String(req.session?.views));
};

function setUpServer(
Expand Down Expand Up @@ -110,7 +91,7 @@ describe('applySession', () => {
await agent.delete('/');
await agent
.get('/')
.expect('0')
.expect('undefined')
.then(({ header }) => expect(header).toHaveProperty('set-cookie'));
// should set cookie since session was destroyed
});
Expand Down

0 comments on commit 6816ae9

Please sign in to comment.