Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove the need to use promisifyStore and refactor #257

Merged
merged 21 commits into from
Aug 31, 2020
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const currentUser = req.session.user; // "John Doe"
Destroy to current session and remove it from session store.

```javascript
if (loggedOut) req.session.destroy();
if (loggedOut) await req.session.destroy();
```

### req.session.commit()
Expand Down Expand Up @@ -251,21 +251,21 @@ The session store to use for session middleware (see `options` above).

### Compatibility with Express/Connect stores

To use [Express/Connect stores](https://github.com/expressjs/session#compatible-session-stores), use `expressSession` and `promisifyStore` from `next-session`.
To use [Express/Connect stores](https://github.com/expressjs/session#compatible-session-stores), you may need to use `expressSession` from `next-session` if the store has the following pattern.

```javascript
import { expressSession, promisifyStore } from 'next-session';
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);

// Use `expressSession` as the replacement

import { expressSession } from 'next-session';
const MongoStore = require('connect-mongo')(expressSession);
const options = {
store: promisifyStore(new MongoStore(options))
}
```

### Implementation

A compatible session store must include three functions: `set(sid, session)`, `get(sid)`, and `destroy(sid)`. The function `touch(sid, session)` is recommended. All functions must return **Promises** (*callbacks* are not supported or must be promisified like above).

The store may emit `store.emit('disconnect')` or `store.emit('connect')` to inform its readiness. (only works with `{ session }`)
A compatible session store must include three functions: `set(sid, session)`, `get(sid)`, and `destroy(sid)`. The function `touch(sid, session)` is recommended. All functions can either return **Promises** or allowing **callback** in the last argument.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"prepublish": "npm run build",
"build": "tsc --outDir dist",
"lint": "eslint src --ext ts --ignore-path .gitignore",
"test": "yarn build && jest --coverageReporters=text-lcov > coverage.lcov"
"test": "jest --colors --coverageReporters=text-lcov > coverage.lcov"
},
"repository": {
"type": "git",
Expand Down
14 changes: 4 additions & 10 deletions src/compat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { promisify, callbackify, inherits } from 'util';
import { callbackify, inherits } from 'util';
import { EventEmitter } from 'events';
import { Store as ExpressStore } from 'express-session';
import { SessionStore } from './types';
import MemoryStore from './store/memory';

export function Store() {
Expand All @@ -28,12 +27,7 @@ expressSession.MemoryStore = CallbackMemoryStore;

export { expressSession };

export function promisifyStore(
store: Pick<ExpressStore, 'get' | 'destroy' | 'set'> & Partial<ExpressStore>
): SessionStore {
store.get = promisify(store.get);
store.set = promisify(store.set);
store.destroy = promisify(store.destroy);
if (store.touch) store.touch = promisify(store.touch);
return store as SessionStore;
export function promisifyStore(store: ExpressStore): ExpressStore {
console.warn('promisifyStore has been deprecated! You can simply remove it.');
return store;
}
11 changes: 7 additions & 4 deletions src/connect.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { applySession } from './core';
import { Options } from './types';
import { Options, SessionData } from './types';
import { IncomingMessage, ServerResponse } from 'http';
import Session from './session';

let storeReady = true;

export default function session<T = {}>(opts?: Options) {
export default function session(opts?: Options) {
// store readiness
if (opts && opts.store && opts.store.on) {
opts.store.on('disconnect', () => {
Expand All @@ -15,7 +14,11 @@ export default function session<T = {}>(opts?: Options) {
storeReady = true;
});
}
return (req: IncomingMessage & { session: Session<T> }, res: ServerResponse, next: (err?: any) => void) => {
return (
req: IncomingMessage & { session: SessionData },
res: ServerResponse,
next: (err?: any) => void
) => {
if (!storeReady) {
next();
return;
Expand Down
54 changes: 0 additions & 54 deletions src/cookie.ts

This file was deleted.

199 changes: 167 additions & 32 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,194 @@
import { parse as parseCookie } from 'cookie';
import { parse, serialize } from 'cookie';
import { nanoid } from 'nanoid';
import MemoryStore from './store/memory';
import Session from './session';
import { Options, SessionOptions } from './types';
import { Store as ExpressStore } from 'express-session';
import { IncomingMessage, ServerResponse } from 'http';
import { promisify } from 'util';
import MemoryStore from './store/memory';
import {
Options,
SessionOptions,
SessionData,
SessionStore,
SessionCookieData,
NormalizedSessionStore,
} from './types';

function getOptions(opts: Options = {}): SessionOptions {
return {
name: opts.name || 'sid',
store: opts.store || new MemoryStore(),
genid: opts.genid || nanoid,
encode: opts.encode,
decode: opts.decode,
rolling: opts.rolling || false,
touchAfter: opts.touchAfter ? opts.touchAfter : 0,
cookie: opts.cookie || {},
autoCommit: typeof opts.autoCommit !== 'undefined' ? opts.autoCommit : true,
};
function isCallbackStore<E extends ExpressStore, S extends SessionStore>(
store: E | S
): store is E {
return store.get.length === 2;
}

const shouldTouch = (cookie: SessionCookieData, touchAfter: number) => {
if (touchAfter === -1 || !cookie.maxAge) return false;
return (
cookie.maxAge * 1000 - (cookie.expires!.getTime() - Date.now()) >=
touchAfter
);
};

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

const commitHead = (
req: IncomingMessage & { session?: SessionData | null },
res: ServerResponse,
options: SessionOptions,
touched: boolean
) => {
if (res.headersSent || !req.session) return;
if (req.session.isNew || (options.rolling && touched)) {
res.setHeader(
'Set-Cookie',
serialize(
options.name,
options.encode ? options.encode(req.session.id) : req.session.id
)
);
}
};

const save = async (
req: IncomingMessage & { session?: SessionData | null },
prevSessStr: string | undefined,
options: SessionOptions,
touched: boolean
) => {
if (!req.session) return;
const obj: SessionData = {} as any;
for (const key in req.session) {
if (!(key === ('isNew' || key === 'id'))) obj[key] = req.session[key];
}

if (stringify(req.session) !== prevSessStr) {
await options.store.__set(req.session.id, obj);
} else if (touched) {
await options.store.__touch?.(req.session.id, obj);
}
};

function setupStore(store: SessionStore | ExpressStore | NormalizedSessionStore) {
if ('__normalized' in store) return store;
const s = (store as unknown) as NormalizedSessionStore;
if (isCallbackStore(store as SessionStore | ExpressStore)) {
s.__destroy = promisify(store.destroy).bind(store);
// @ts-ignore
s.__get = promisify(store.get).bind(store);
// @ts-ignore
s.__set = promisify(store.set).bind(store);
if (store.touch)
// @ts-ignore
s.__touch = promisify(store.touch).bind(store);
} else {
s.__destroy = store.destroy.bind(store);
s.__get = store.get.bind(store);
s.__set = store.set.bind(store);
s.__touch = store.touch?.bind(store);
}
s.__normalized = true;
return s;
}

let memoryStore: MemoryStore;

export async function applySession<T = {}>(
req: IncomingMessage & { session: Session<T> },
req: IncomingMessage & { session: SessionData },
res: ServerResponse,
opts?: Options
): Promise<void> {
const options = getOptions(opts);

if (req.session) return;

let sessId = req.headers && req.headers.cookie
? parseCookie(req.headers.cookie)[options.name]
: null;
const options: SessionOptions = {
name: opts?.name || 'sid',
store: setupStore(
opts?.store || (memoryStore = memoryStore || new MemoryStore())
),
genid: opts?.genid || nanoid,
encode: opts?.encode,
decode: opts?.decode,
rolling: opts?.rolling || false,
touchAfter: opts?.touchAfter ? opts.touchAfter : 0,
cookie: {
path: opts?.cookie?.path || '/',
maxAge: opts?.cookie?.maxAge || null,
httpOnly: opts?.cookie?.httpOnly || true,
domain: opts?.cookie?.domain || undefined,
sameSite: opts?.cookie?.sameSite,
secure: opts?.cookie?.secure || false,
},
autoCommit:
typeof opts?.autoCommit !== 'undefined' ? opts.autoCommit : true,
};

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

(req as any).sessionStore = options.store;
if (sessId && options.decode) sessId = options.decode(sessId);

const sess = sessId ? await options.store.get(sessId) : null;
if (sess) sess.id = sessId;
req.session = new Session<T>(res, options, sess);
const sess = sessId ? await options.store.__get(sessId) : null;

let touched = false;

const commit = async () => {
commitHead(req, res, options, touched);
await save(req, prevSessStr, options, touched);
};

const destroy = async () => {
await options.store.__destroy(req.session.id);
delete req.session;
};

if (sess) {
const { cookie, ...data } = sess;
if (typeof cookie.expires === 'string')
cookie.expires = new Date(cookie.expires);
req.session = {
cookie,
commit,
destroy,
isNew: false,
id: sessId!,
};
for (const key in data) req.session[key] = data[key];
} else {
req.session = {
cookie: options.cookie,
commit,
destroy,
isNew: true,
id: nanoid(),
};
if (options.cookie.maxAge) req.session.cookie.expires = new Date();
}

// Extend session expiry
if ((touched = shouldTouch(req.session.cookie, options.touchAfter))) {
req.session.cookie.expires = new Date(
Date.now() + req.session.cookie.maxAge! * 1000
);
}

const prevSessStr: string | undefined = sess ? stringify(sess) : undefined;

// autocommit
if (options.autoCommit) {
const oldWritehead = res.writeHead;
res.writeHead = function resWriteHeadProxy(...args: any) {
req.session.commitHead();
commitHead(req, res, options, touched);
return oldWritehead.apply(this, args);
}
};
const oldEnd = res.end;
res.end = async function resEndProxy(...args: any) {
await req.session.save();
await save(req, prevSessStr, options, touched);
oldEnd.apply(this, args);
};
}

// Compat
(req as any).sessionStore = options.store;
}
Loading