diff --git a/Readme.md b/Readme.md index 3edb50d..9b6720f 100644 --- a/Readme.md +++ b/Readme.md @@ -56,7 +56,8 @@ const CONFIG = { overwrite: true, /** (boolean) can overwrite or not (default true) */ httpOnly: true, /** (boolean) httpOnly or not (default true) */ signed: true, /** (boolean) signed or not (default true) */ - rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. default is false **/ + rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */ + renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/ }; app.use(session(CONFIG, app)); diff --git a/lib/context.js b/lib/context.js index 77095ad..df162ee 100644 --- a/lib/context.js +++ b/lib/context.js @@ -207,7 +207,6 @@ class ContextSession { async commit() { const session = this.session; - const prevHash = this.prevHash; const opts = this.opts; const ctx = this.ctx; @@ -220,24 +219,47 @@ class ContextSession { return; } - // force save session when `session._requireSave` set - let changed = true; - if (!session._requireSave) { - const json = session.toJSON(); - // do nothing if new and not populated - if (!prevHash && !Object.keys(json).length) return; - changed = prevHash !== util.hash(json); - // do nothing if not changed and not in rolling mode - if (!this.opts.rolling && !changed) return; - } + const reason = this._shouldSaveSession(); + debug('should save session: %s', reason); + if (!reason) return; if (typeof opts.beforeSave === 'function') { debug('before save'); opts.beforeSave(ctx, session); } + const changed = reason === 'changed'; await this.save(changed); } + _shouldSaveSession() { + const prevHash = this.prevHash; + const session = this.session; + + // force save session when `session._requireSave` set + if (session._requireSave) return 'force'; + + // do nothing if new and not populated + const json = session.toJSON(); + if (!prevHash && !Object.keys(json).length) return ''; + + // save if session changed + const changed = prevHash !== util.hash(json); + if (changed) return 'changed'; + + // save if opts.rolling set + if (this.opts.rolling) return 'rolling'; + + // save if opts.renew and session will expired + if (this.opts.renew) { + const expire = session._expire; + const maxAge = session.maxAge; + // renew when session will expired in maxAge / 2 + if (expire && maxAge && expire - Date.now() < maxAge / 2) return 'renew'; + } + + return ''; + } + /** * remove session * @api private @@ -264,7 +286,7 @@ class ContextSession { const externalKey = this.externalKey; let json = this.session.toJSON(); // set expire for check - const maxAge = opts.maxAge ? opts.maxAge : ONE_DAY; + let maxAge = opts.maxAge ? opts.maxAge : ONE_DAY; if (maxAge === 'session') { // do not set _expire in json if maxAge is set to 'session' // also delete maxAge from options @@ -278,6 +300,10 @@ class ContextSession { // save to external store if (externalKey) { debug('save %j to external key %s', json, externalKey); + if (typeof maxAge === 'number') { + // ensure store expired after cookie + maxAge += 10000; + } await this.store.set(externalKey, json, maxAge, { changed, rolling: opts.rolling, diff --git a/package.json b/package.json index 674c0aa..3a61042 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "koa": "2", "mm": "^2.1.0", "mocha": "3", + "mz-modules": "^2.0.0", "should": "8", "supertest": "2" }, diff --git a/test/store.test.js b/test/store.test.js index b582d4e..9943ffb 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -8,6 +8,7 @@ const session = require('..'); const store = require('./store'); const pedding = require('pedding'); const assert = require('assert'); +const sleep = require('mz-modules/sleep'); describe('Koa Session External Store', () => { let cookie; @@ -717,6 +718,57 @@ describe('Koa Session External Store', () => { }); }); }); + + describe('when renew set to true', () => { + let app; + before(() => { + app = App({ renew: true, maxAge: 2000 }); + + app.use(async ctx => { + if (ctx.path === '/set') ctx.session = { foo: 'bar' }; + ctx.body = ctx.session; + }); + }); + + it('should not send set-cookie when session not exists', () => { + return request(app.callback()) + .get('/') + .expect({}) + .expect(res => { + should.not.exist(res.headers['set-cookie']); + }); + }); + + it('should send set-cookie when session near expire and not change', async () => { + let res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + res.headers['set-cookie'].should.have.length(2); + const cookie = res.headers['set-cookie'].join(';'); + await sleep(1200); + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + res.headers['set-cookie'].should.have.length(2); + }); + + it('should not send set-cookie when session not near expire and not change', async () => { + let res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + res.headers['set-cookie'].should.have.length(2); + const cookie = res.headers['set-cookie'].join(';'); + await sleep(500); + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + should.not.exist(res.headers['set-cookie']); + }); + }); }); function App(options) {