Skip to content

Commit 3016efd

Browse files
authored
Refactor, remove rolling option and fix unreliable tests (#283)
* Remove rolling option * Fix test * Update touch behavior * Fix session date * Add deprecated opts.rolling compat * update depre rolling check * Refactor * Update README * refactor * Cleanup and type fixes * type fixes * Fix Jest * Update test * Explicit test for touch in callback store * Fix touch
1 parent cc04104 commit 3016efd

File tree

11 files changed

+328
-248
lines changed

11 files changed

+328
-248
lines changed

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,9 @@ await applySession(req, res, options);
178178
| name | The name of the cookie to be read from the request and set to the response. | `sid` |
179179
| store | The session store instance to be used. | `MemoryStore` |
180180
| genid | The function that generates a string for a new session ID. | [`nanoid`](https://github.com/ai/nanoid) |
181-
| encode | Transforms session ID before setting cookie. It should return the encoded/encrypted session ID. | undefined |
182-
| decode | Transforms session ID back while getting from cookie. It takes the raw session ID and returns the decoded/decrypted session ID. | undefined |
183-
| touchAfter | Only touch (extend session lifetime despite no modification) after an amount of time to decrease database load. Setting the value to `-1` will disable `touch()`. | `0` (Touch every time) |
184-
| rolling | Extends the life time of the cookie in the browser if the session is touched. This respects touchAfter. | `false` |
181+
| encode | Transforms session ID before setting cookie. It takes the raw session ID and returns the decoded/decrypted session ID. | undefined |
182+
| decode | Transforms session ID back while getting from cookie. It should return the encoded/encrypted session ID | undefined |
183+
| touchAfter | Only touch after an amount of time. Disabled by default or if set to `-1`. See [touchAfter](#touchAfter). | `-1` (Disabled) |
185184
| autoCommit | Automatically commit session. Disable this if you want to manually `session.commit()` | `true` |
186185
| cookie.secure | Specifies the boolean value for the **Secure** `Set-Cookie` attribute. | `false` |
187186
| cookie.httpOnly | Specifies the boolean value for the **httpOnly** `Set-Cookie` attribute. | `true` |
@@ -190,6 +189,12 @@ await applySession(req, res, options);
190189
| cookie.sameSite | Specifies the value for the **SameSite** `Set-Cookie` attribute. | unset |
191190
| cookie.maxAge | **(in seconds)** Specifies the value for the **Max-Age** `Set-Cookie` attribute. | unset (Browser session) |
192191

192+
### touchAfter
193+
194+
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.
195+
196+
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.
197+
193198
### encode/decode
194199

195200
You may supply a custom pair of function that *encode/decode* or *encrypt/decrypt* the cookie on every request.

src/compat.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ export function Store() {
88
EventEmitter.call(this);
99
}
1010
inherits(Store, EventEmitter);
11-
// no-op for compat
1211

12+
// no-op for compat
1313
function expressSession(options?: any): any {}
1414

1515
expressSession.Store = Store;
@@ -28,6 +28,8 @@ expressSession.MemoryStore = CallbackMemoryStore;
2828
export { expressSession };
2929

3030
export function promisifyStore(store: ExpressStore): ExpressStore {
31-
console.warn('promisifyStore has been deprecated! You can simply remove it.');
31+
console.warn(
32+
'promisifyStore has been deprecated: express-session store still works without using this.'
33+
);
3234
return store;
3335
}

src/connect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { applySession } from './core';
2-
import { Options, SessionData } from './types';
2+
import { Options } from './types';
33
import { IncomingMessage, ServerResponse } from 'http';
44

55
let storeReady = true;
@@ -15,7 +15,7 @@ export default function session(opts?: Options) {
1515
});
1616
}
1717
return (
18-
req: IncomingMessage & { session: SessionData },
18+
req: IncomingMessage,
1919
res: ServerResponse,
2020
next: (err?: any) => void
2121
) => {

src/core.ts

Lines changed: 115 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -4,79 +4,51 @@ import { Store as ExpressStore } from 'express-session';
44
import { IncomingMessage, ServerResponse } from 'http';
55
import MemoryStore from './store/memory';
66
import {
7+
Session,
78
Options,
89
SessionData,
910
SessionStore,
10-
SessionCookieData,
1111
NormalizedSessionStore,
1212
} from './types';
1313

14-
type SessionOptions = Omit<
15-
Required<Options>,
16-
'encode' | 'decode' | 'store' | 'cookie'
17-
> &
18-
Pick<Options, 'encode' | 'decode'> & {
19-
store: NormalizedSessionStore;
20-
};
21-
22-
const shouldTouch = (cookie: SessionCookieData, touchAfter: number) => {
23-
if (touchAfter === -1 || !cookie.maxAge) return false;
24-
return (
25-
cookie.maxAge * 1000 - (cookie.expires!.getTime() - Date.now()) >=
26-
touchAfter
27-
);
28-
};
29-
30-
const stringify = (sess: SessionData) =>
31-
JSON.stringify(sess, (key, val) =>
32-
key === 'cookie' || key === 'isNew' || key === 'id' ? undefined : val
33-
);
34-
35-
const SESS_PREV = Symbol('session#prev');
36-
const SESS_TOUCHED = Symbol('session#touched');
14+
const stringify = (sess: SessionData | null | undefined) =>
15+
JSON.stringify(sess, (key, val) => (key === 'cookie' ? undefined : val));
3716

3817
const commitHead = (
39-
req: IncomingMessage & { session?: SessionData | null },
4018
res: ServerResponse,
41-
options: SessionOptions
19+
name: string,
20+
session: SessionData | null | undefined,
21+
touched: boolean,
22+
encodeFn?: Options['encode']
4223
) => {
43-
if (res.headersSent || !req.session) return;
44-
if (req.session.isNew || (options.rolling && (req as any)[SESS_TOUCHED])) {
24+
if (res.headersSent || !session) return;
25+
if (session.isNew || touched) {
4526
res.setHeader(
4627
'Set-Cookie',
47-
serialize(
48-
options.name,
49-
options.encode ? options.encode(req.session.id) : req.session.id,
50-
{
51-
path: req.session.cookie.path,
52-
httpOnly: req.session.cookie.httpOnly,
53-
expires: req.session.cookie.expires,
54-
domain: req.session.cookie.domain,
55-
sameSite: req.session.cookie.sameSite,
56-
secure: req.session.cookie.secure,
57-
}
58-
)
28+
serialize(name, encodeFn ? encodeFn(session.id) : session.id, {
29+
path: session.cookie.path,
30+
httpOnly: session.cookie.httpOnly,
31+
expires: session.cookie.expires,
32+
domain: session.cookie.domain,
33+
sameSite: session.cookie.sameSite,
34+
secure: session.cookie.secure,
35+
})
5936
);
6037
}
6138
};
6239

63-
const save = async (
64-
req: IncomingMessage & { session?: SessionData | null },
65-
options: SessionOptions
66-
) => {
67-
if (!req.session) return;
40+
const prepareSession = (session: SessionData) => {
6841
const obj: SessionData = {} as any;
69-
for (const key in req.session) {
70-
if (!(key === ('isNew' || key === 'id'))) obj[key] = req.session[key];
71-
}
72-
73-
if (stringify(req.session) !== (req as any)[SESS_PREV]) {
74-
await options.store.__set(req.session.id, obj);
75-
} else if ((req as any)[SESS_TOUCHED]) {
76-
await options.store.__touch?.(req.session.id, obj);
77-
}
42+
for (const key in session)
43+
!(key === ('isNew' || key === 'id')) && (obj[key] = session[key]);
44+
return obj;
7845
};
7946

47+
const save = async (
48+
store: NormalizedSessionStore,
49+
session: SessionData | null | undefined
50+
) => session && store.__set(session.id, prepareSession(session));
51+
8052
function setupStore(
8153
store: SessionStore | ExpressStore | NormalizedSessionStore
8254
) {
@@ -94,8 +66,9 @@ function setupStore(
9466

9567
s.__get = function get(sid) {
9668
return new Promise((resolve, reject) => {
97-
const done = (err: any, val: SessionData) =>
69+
const done = (err: any, val: SessionData | null | undefined) =>
9870
err ? reject(err) : resolve(val);
71+
// @ts-ignore: Certain differences between express-session type and ours
9972
const result = this.get(sid, done);
10073
if (result && typeof result.then === 'function')
10174
result.then(resolve, reject);
@@ -105,6 +78,7 @@ function setupStore(
10578
s.__set = function set(sid, sess) {
10679
return new Promise((resolve, reject) => {
10780
const done = (err: any) => (err ? reject(err) : resolve());
81+
// @ts-ignore: Certain differences between express-session type and ours
10882
const result = this.set(sid, sess, done);
10983
if (result && typeof result.then === 'function')
11084
result.then(resolve, reject);
@@ -115,7 +89,8 @@ function setupStore(
11589
s.__touch = function touch(sid, sess) {
11690
return new Promise((resolve, reject) => {
11791
const done = (err: any) => (err ? reject(err) : resolve());
118-
const result = this.touch(sid, sess, done);
92+
// @ts-ignore: Certain differences between express-session type and ours
93+
const result = this.touch!(sid, sess, done);
11994
if (result && typeof result.then === 'function')
12095
result.then(resolve, reject);
12196
});
@@ -129,105 +104,124 @@ function setupStore(
129104
let memoryStore: MemoryStore;
130105

131106
export async function applySession<T = {}>(
132-
req: IncomingMessage & { session: SessionData },
107+
req: IncomingMessage & { session?: Session | null | undefined },
133108
res: ServerResponse,
134-
opts?: Options
109+
options: Options = {}
135110
): Promise<void> {
136111
if (req.session) return;
137112

138-
const options: SessionOptions = {
139-
name: opts?.name || 'sid',
140-
store: setupStore(
141-
opts?.store || (memoryStore = memoryStore || new MemoryStore())
142-
),
143-
genid: opts?.genid || nanoid,
144-
encode: opts?.encode,
145-
decode: opts?.decode,
146-
rolling: opts?.rolling || false,
147-
touchAfter: opts?.touchAfter ? opts.touchAfter : 0,
148-
autoCommit:
149-
typeof opts?.autoCommit !== 'undefined' ? opts.autoCommit : true,
150-
};
151-
152-
let sessId =
153-
req.headers && req.headers.cookie
154-
? parse(req.headers.cookie)[options.name]
155-
: null;
113+
// This allows both promised-based and callback-based store to work
114+
const store: NormalizedSessionStore = setupStore(
115+
options.store || (memoryStore = memoryStore || new MemoryStore())
116+
);
156117

157-
if (sessId && options.decode) sessId = options.decode(sessId);
118+
// compat: if rolling is `true`, user might have wanted to touch every time
119+
// thus defaulting options.touchAfter to 0 instead of -1
120+
if (options.rolling && !('touchAfter' in options)) {
121+
console.warn(
122+
'The use of options.rolling is deprecated. Setting this to `true` without options.touchAfter causes options.touchAfter to be defaulted to `0` (always)'
123+
);
124+
options.touchAfter = 0;
125+
}
158126

159-
const sess = sessId ? await options.store.__get(sessId) : null;
127+
const name = options.name || 'sid';
160128

161129
const commit = async () => {
162-
commitHead(req, res, options);
163-
await save(req, options);
130+
commitHead(res, name, req.session, shouldTouch, options.encode);
131+
await save(store, req.session);
164132
};
165133

166134
const destroy = async () => {
167-
await options.store.__destroy(req.session.id);
168-
// This is a valid TS error, but considering its usage, it's fine.
169-
// @ts-ignore
170-
delete req.session;
135+
await store.__destroy(req.session!.id);
136+
req.session = null;
171137
};
172138

173-
if (sess) {
174-
(req as any)[SESS_PREV] = stringify(sess);
175-
const { cookie, ...data } = sess;
176-
if (typeof cookie.expires === 'string')
177-
cookie.expires = new Date(cookie.expires);
178-
req.session = {
179-
cookie,
180-
commit,
181-
destroy,
182-
isNew: false,
183-
id: sessId!,
184-
};
185-
for (const key in data) req.session[key] = data[key];
139+
let sessId =
140+
req.headers && req.headers.cookie ? parse(req.headers.cookie)[name] : null;
141+
if (sessId && options.decode) sessId = options.decode(sessId);
142+
143+
// @ts-ignore: req.session as this point is not of type Session
144+
// but SessionData, but the missing keys will be added later
145+
req.session = sessId ? await store.__get(sessId) : null;
146+
147+
if (req.session) {
148+
req.session.commit = commit;
149+
req.session.destroy = destroy;
150+
req.session.isNew = false;
151+
req.session.id = sessId!;
152+
// Some store return cookie.expires as string, convert it to Date
153+
if (typeof req.session.cookie.expires === 'string')
154+
req.session.cookie.expires = new Date(req.session.cookie.expires);
186155
} else {
187-
(req as any)[SESS_PREV] = '{}';
188156
req.session = {
189157
cookie: {
190-
path: opts?.cookie?.path || '/',
191-
maxAge: opts?.cookie?.maxAge || null,
192-
httpOnly: opts?.cookie?.httpOnly || true,
193-
domain: opts?.cookie?.domain || undefined,
194-
sameSite: opts?.cookie?.sameSite,
195-
secure: opts?.cookie?.secure || false,
158+
path: options.cookie?.path || '/',
159+
httpOnly: options.cookie?.httpOnly || true,
160+
domain: options.cookie?.domain || undefined,
161+
sameSite: options.cookie?.sameSite,
162+
secure: options.cookie?.secure || false,
163+
...(options.cookie?.maxAge
164+
? { maxAge: options.cookie.maxAge, expires: new Date() }
165+
: { maxAge: null }),
196166
},
197167
commit,
198168
destroy,
199169
isNew: true,
200-
id: options.genid(),
170+
id: (options.genid || nanoid)(),
201171
};
202-
if (opts?.cookie?.maxAge) req.session.cookie.expires = new Date();
203172
}
204173

205-
// Extend session expiry
206-
if (
207-
((req as any)[SESS_TOUCHED] = shouldTouch(
208-
req.session.cookie,
209-
options.touchAfter
210-
))
211-
) {
212-
req.session.cookie.expires = new Date(
213-
Date.now() + req.session.cookie.maxAge! * 1000
214-
);
174+
// prevSessStr is used to compare the session later
175+
// for touchability -- that is, we only touch the
176+
// session if it has changed. This check is used
177+
// in autoCommit mode only
178+
const prevSessStr: string | undefined =
179+
options.autoCommit !== false
180+
? req.session.isNew
181+
? '{}'
182+
: stringify(req.session)
183+
: undefined;
184+
185+
let shouldTouch = false;
186+
187+
if (req.session.cookie.maxAge) {
188+
if (
189+
// Extend expires either if it is a new session
190+
req.session.isNew ||
191+
// or if touchAfter condition is satsified
192+
(typeof options.touchAfter === 'number' &&
193+
options.touchAfter !== -1 &&
194+
(shouldTouch =
195+
req.session.cookie.maxAge * 1000 -
196+
(req.session.cookie.expires.getTime() - Date.now()) >=
197+
options.touchAfter))
198+
) {
199+
req.session.cookie.expires = new Date(
200+
Date.now() + req.session.cookie.maxAge * 1000
201+
);
202+
}
215203
}
216204

217-
// autocommit
218-
if (options.autoCommit) {
205+
// autocommit: We commit the header and save the session automatically
206+
// by "proxying" res.writeHead and res.end methods. After committing, we
207+
// call the original res.writeHead and res.end.
208+
if (options.autoCommit !== false) {
219209
const oldWritehead = res.writeHead;
220210
res.writeHead = function resWriteHeadProxy(...args: any) {
221-
commitHead(req, res, options);
211+
commitHead(res, name, req.session, shouldTouch, options.encode);
222212
return oldWritehead.apply(this, args);
223213
};
224214
const oldEnd = res.end;
225215
res.end = async function resEndProxy(...args: any) {
226-
await save(req, options);
216+
if (stringify(req.session) !== prevSessStr) {
217+
await save(store, req.session);
218+
} else if (req.session && shouldTouch && store.__touch) {
219+
await store.__touch(req.session!.id, prepareSession(req.session!));
220+
}
227221
oldEnd.apply(this, args);
228222
};
229223
}
230224

231225
// Compat
232-
(req as any).sessionStore = options.store;
226+
(req as any).sessionStore = store;
233227
}

0 commit comments

Comments
 (0)