diff --git a/.taprc b/.taprc index 071f034..eb6eb3e 100644 --- a/.taprc +++ b/.taprc @@ -1,3 +1,2 @@ -100: true -check-coverage: true -coverage: true +files: + - test/**/*.test.js diff --git a/benchmark/cookie-multi.js b/benchmark/cookie-multi.js new file mode 100644 index 0000000..6815b1b --- /dev/null +++ b/benchmark/cookie-multi.js @@ -0,0 +1,24 @@ +'use strict' + +const Fastify = require('fastify') +const plugin = require('../') + +const secret = 'testsecret' + +const fastify = Fastify() +fastify.register(plugin, { secret }) + +fastify.get('/', (req, reply) => { + reply + .setCookie('foo', 'foo') + .setCookie('foo', 'foo', { path: '/1' }) + .setCookie('boo', 'boo', { path: '/' }) + .setCookie('foo', 'foo-different', { path: '/' }) + .setCookie('foo', 'foo', { path: '/2' }) + .send({ hello: 'world' }) +}) + +fastify.listen({ host: '127.0.0.1', port: 5001 }, (err, address) => { + if (err) throw err + console.log(address) +}) diff --git a/benchmark/cookie.js b/benchmark/cookie.js new file mode 100644 index 0000000..c5f8b37 --- /dev/null +++ b/benchmark/cookie.js @@ -0,0 +1,20 @@ +'use strict' + +const Fastify = require('fastify') +const plugin = require('../') + +const secret = 'testsecret' + +const fastify = Fastify() +fastify.register(plugin, { secret }) + +fastify.get('/', (req, reply) => { + reply + .setCookie('foo', 'foo', { path: '/' }) + .send({ hello: 'world' }) +}) + +fastify.listen({ host: '127.0.0.1', port: 5001 }, (err, address) => { + if (err) throw err + console.log(address) +}) diff --git a/package.json b/package.json index 288164f..f69c1be 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "main": "plugin.js", "types": "types/plugin.d.ts", "scripts": { + "coverage": "npm run test:unit -- --coverage-report=html", "lint": "standard | snazzy", "lint:ci": "standard", "lint:fix": "standard --fix", - "test": "npm run unit && npm run typescript", - "typescript": "tsd", - "unit": "tap -J \"test/*.test.js\"", - "unit:report": "npm run unit -- --coverage-report=html", - "unit:verbose": "npm run unit -- -Rspec" + "test": "npm run test:unit && npm run test:typescript", + "test:typescript": "tsd", + "test:unit": "tap", + "test:unit:verbose": "npm run test:unit -- -Rspec" }, "precommit": [ "lint", diff --git a/plugin.js b/plugin.js index 8774e6a..9017494 100644 --- a/plugin.js +++ b/plugin.js @@ -5,14 +5,17 @@ const cookie = require('cookie') const { Signer, sign, unsign } = require('./signer') -function fastifyCookieSetCookie (reply, name, value, options, signer) { +const kReplySetCookies = Symbol('fastify.reply.setCookies') + +function fastifyCookieSetCookie (reply, name, value, options) { const opts = Object.assign({}, options) + if (opts.expires && Number.isInteger(opts.expires)) { opts.expires = new Date(opts.expires) } if (opts.signed) { - value = signer.sign(value) + value = reply.signCookie(value) } if (opts.secure === 'auto') { @@ -24,20 +27,8 @@ function fastifyCookieSetCookie (reply, name, value, options, signer) { } } - const serialized = cookie.serialize(name, value, opts) - let setCookie = reply.getHeader('Set-Cookie') - if (!setCookie) { - reply.header('Set-Cookie', serialized) - return reply - } - - if (typeof setCookie === 'string') { - setCookie = [setCookie] - } + reply[kReplySetCookies].set(`${name};${opts.domain};${opts.path || '/'}`, { name, value, opts }) - setCookie.push(serialized) - reply.removeHeader('Set-Cookie') - reply.header('Set-Cookie', setCookie) return reply } @@ -58,6 +49,7 @@ function onReqHandlerWrapper (fastify, hook) { if (cookieHeader) { fastifyReq.cookies = fastify.parseCookie(cookieHeader) } + fastifyRes[kReplySetCookies] = new Map() done() } : function fastifyCookieHandler (fastifyReq, fastifyRes, done) { @@ -66,10 +58,41 @@ function onReqHandlerWrapper (fastify, hook) { if (cookieHeader) { fastifyReq.cookies = fastify.parseCookie(cookieHeader) } + fastifyRes[kReplySetCookies] = new Map() done() } } +function fastifyCookieOnSendHandler (fastifyReq, fastifyRes, payload, done) { + if (fastifyRes[kReplySetCookies].size) { + let setCookie = fastifyRes.getHeader('Set-Cookie') + + /* istanbul ignore else */ + if (setCookie === undefined) { + if (fastifyRes[kReplySetCookies].size === 1) { + for (const c of fastifyRes[kReplySetCookies].values()) { + fastifyRes.header('Set-Cookie', cookie.serialize(c.name, c.value, c.opts)) + } + + return done() + } + + setCookie = [] + } else if (typeof setCookie === 'string') { + setCookie = [setCookie] + } + + for (const c of fastifyRes[kReplySetCookies].values()) { + setCookie.push(cookie.serialize(c.name, c.value, c.opts)) + } + + fastifyRes.removeHeader('Set-Cookie') + fastifyRes.header('Set-Cookie', setCookie) + } + + done() +} + function getHook (hook = 'onRequest') { const hooks = { onRequest: 'onRequest', @@ -89,9 +112,9 @@ function plugin (fastify, options, next) { return next(new Error('@fastify/cookie: Invalid value provided for the hook-option. You can set the hook-option only to false, \'onRequest\' , \'preParsing\' , \'preValidation\' or \'preHandler\'')) } const isSigner = !secret || (typeof secret.sign === 'function' && typeof secret.unsign === 'function') - const algorithm = options.algorithm || 'sha256' - const signer = isSigner ? secret : new Signer(secret, algorithm) + const signer = isSigner ? secret : new Signer(secret, options.algorithm || 'sha256') + fastify.decorate('serializeCookie', cookie.serialize) fastify.decorate('parseCookie', parseCookie) if (typeof secret !== 'undefined') { @@ -106,13 +129,15 @@ function plugin (fastify, options, next) { } fastify.decorateRequest('cookies', null) - fastify.decorateReply('cookie', setCookie) + fastify.decorateReply(kReplySetCookies, null) + fastify.decorateReply('cookie', setCookie) fastify.decorateReply('setCookie', setCookie) fastify.decorateReply('clearCookie', clearCookie) if (hook) { fastify.addHook(hook, onReqHandlerWrapper(fastify, hook)) + fastify.addHook('onSend', fastifyCookieOnSendHandler) } next() @@ -132,7 +157,7 @@ function plugin (fastify, options, next) { function setCookie (name, value, cookieOptions) { const opts = Object.assign({}, options.parseOptions, cookieOptions) - return fastifyCookieSetCookie(this, name, value, opts, signer) + return fastifyCookieSetCookie(this, name, value, opts) } function clearCookie (name, cookieOptions) { diff --git a/test/cookie.test.js b/test/cookie.test.js index eb17979..eb5db80 100644 --- a/test/cookie.test.js +++ b/test/cookie.test.js @@ -123,7 +123,7 @@ test('cookies get set correctly with millisecond dates', (t) => { }) test('share options for setCookie and clearCookie', (t) => { - t.plan(11) + t.plan(8) const fastify = Fastify() const secret = 'testsecret' fastify.register(plugin, { secret }) @@ -149,20 +149,17 @@ test('share options for setCookie and clearCookie', (t) => { t.same(JSON.parse(res.body), { hello: 'world' }) const cookies = res.cookies - t.equal(cookies.length, 2) + t.equal(cookies.length, 1) t.equal(cookies[0].name, 'foo') - t.equal(cookies[0].value, sign('foo', secret)) - t.equal(cookies[0].maxAge, 36000) + t.equal(cookies[0].value, '') + t.equal(cookies[0].maxAge, undefined) - t.equal(cookies[1].name, 'foo') - t.equal(cookies[1].value, '') - t.equal(cookies[1].path, '/') - t.ok(new Date(cookies[1].expires) < new Date()) + t.ok(new Date(cookies[0].expires) < new Date()) }) }) test('expires should not be overridden in clearCookie', (t) => { - t.plan(11) + t.plan(7) const fastify = Fastify() const secret = 'testsecret' fastify.register(plugin, { secret }) @@ -188,16 +185,11 @@ test('expires should not be overridden in clearCookie', (t) => { t.same(JSON.parse(res.body), { hello: 'world' }) const cookies = res.cookies - t.equal(cookies.length, 2) + t.equal(cookies.length, 1) t.equal(cookies[0].name, 'foo') - t.equal(cookies[0].value, sign('foo', secret)) + t.equal(cookies[0].value, '') const expires = new Date(cookies[0].expires) t.ok(expires < new Date(Date.now() + 5000)) - - t.equal(cookies[1].name, 'foo') - t.equal(cookies[1].value, '') - t.equal(cookies[1].path, '/') - t.equal(Number(cookies[1].expires), 0) }) }) @@ -711,6 +703,18 @@ test('issue 53', (t) => { }) }) +test('serialize cookie manually using decorator', (t) => { + t.plan(2) + const fastify = Fastify() + fastify.register(plugin) + + fastify.ready(() => { + t.ok(fastify.serializeCookie) + t.same(fastify.serializeCookie('foo', 'bar', {}), 'foo=bar') + t.end() + }) +}) + test('parse cookie manually using decorator', (t) => { t.plan(2) const fastify = Fastify() @@ -944,7 +948,7 @@ test('if cookies are not set, then the handler creates an empty req.cookies obje }) test('clearCookie should include parseOptions', (t) => { - t.plan(14) + t.plan(10) const fastify = Fastify() fastify.register(plugin, { parseOptions: { @@ -975,17 +979,179 @@ test('clearCookie should include parseOptions', (t) => { const cookies = res.cookies - t.equal(cookies.length, 2) + t.equal(cookies.length, 1) t.equal(cookies[0].name, 'foo') - t.equal(cookies[0].value, 'foo') - t.equal(cookies[0].maxAge, 36000) + t.equal(cookies[0].value, '') + t.equal(cookies[0].maxAge, undefined) t.equal(cookies[0].path, '/test') t.equal(cookies[0].domain, 'example.com') + t.ok(new Date(cookies[0].expires) < new Date()) + }) +}) + +test('should update a cookie value when setCookie is called multiple times', (t) => { + t.plan(15) + const fastify = Fastify() + const secret = 'testsecret' + fastify.register(plugin, { secret }) + + const cookieOptions = { + signed: true, + path: '/foo', + maxAge: 36000 + } + + const cookieOptions2 = { + signed: true, + maxAge: 36000 + } + + fastify.get('/test1', (req, reply) => { + reply + .setCookie('foo', 'foo', cookieOptions) + .clearCookie('foo', cookieOptions) + .setCookie('foo', 'foo', cookieOptions2) + .setCookie('foos', 'foos', cookieOptions) + .setCookie('foos', 'foosy', cookieOptions) + .send({ hello: 'world' }) + }) + + fastify.inject({ + method: 'GET', + url: '/test1' + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + t.same(JSON.parse(res.body), { hello: 'world' }) + + const cookies = res.cookies + t.equal(cookies.length, 3) + + t.equal(cookies[0].name, 'foo') + t.equal(cookies[0].value, '') + t.equal(cookies[0].path, '/foo') + + t.equal(cookies[1].name, 'foo') + t.equal(cookies[1].value, sign('foo', secret)) + t.equal(cookies[1].maxAge, 36000) + + t.equal(cookies[2].name, 'foos') + t.equal(cookies[2].value, sign('foosy', secret)) + t.equal(cookies[2].path, '/foo') + t.equal(cookies[2].maxAge, 36000) + + t.ok(new Date(cookies[0].expires) < new Date()) + }) +}) + +test('should update a cookie value when setCookie is called multiple times (empty header)', (t) => { + t.plan(15) + const fastify = Fastify() + const secret = 'testsecret' + fastify.register(plugin, { secret }) + + const cookieOptions = { + signed: true, + path: '/foo', + maxAge: 36000 + } + + const cookieOptions2 = { + signed: true, + maxAge: 36000 + } + + fastify.get('/test1', (req, reply) => { + reply + .header('Set-Cookie', '', cookieOptions) + .setCookie('foo', 'foo', cookieOptions) + .clearCookie('foo', cookieOptions) + .setCookie('foo', 'foo', cookieOptions2) + .setCookie('foos', 'foos', cookieOptions) + .setCookie('foos', 'foosy', cookieOptions) + .send({ hello: 'world' }) + }) + + fastify.inject({ + method: 'GET', + url: '/test1' + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + t.same(JSON.parse(res.body), { hello: 'world' }) + + const cookies = res.cookies + t.equal(cookies.length, 3) + + t.equal(cookies[0].name, 'foo') + t.equal(cookies[0].value, '') + t.equal(cookies[0].path, '/foo') + + t.equal(cookies[1].name, 'foo') + t.equal(cookies[1].value, sign('foo', secret)) + t.equal(cookies[1].maxAge, 36000) + + t.equal(cookies[2].name, 'foos') + t.equal(cookies[2].value, sign('foosy', secret)) + t.equal(cookies[2].path, '/foo') + t.equal(cookies[2].maxAge, 36000) + + t.ok(new Date(cookies[0].expires) < new Date()) + }) +}) + +test('should update a cookie value when setCookie is called multiple times (non-empty header)', (t) => { + t.plan(15) + const fastify = Fastify() + const secret = 'testsecret' + fastify.register(plugin, { secret }) + + const cookieOptions = { + signed: true, + path: '/foo', + maxAge: 36000 + } + + const cookieOptions2 = { + signed: true, + maxAge: 36000 + } + + fastify.get('/test1', (req, reply) => { + reply + .header('Set-Cookie', 'manual=manual', cookieOptions) + .setCookie('foo', 'foo', cookieOptions) + .clearCookie('foo', cookieOptions) + .setCookie('foo', 'foo', cookieOptions2) + .setCookie('foos', 'foos', cookieOptions) + .setCookie('foos', 'foosy', cookieOptions) + .send({ hello: 'world' }) + }) + + fastify.inject({ + method: 'GET', + url: '/test1' + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + t.same(JSON.parse(res.body), { hello: 'world' }) + + const cookies = res.cookies + t.equal(cookies.length, 4) + t.equal(cookies[1].name, 'foo') t.equal(cookies[1].value, '') - t.equal(cookies[1].path, '/test') - t.equal(cookies[1].domain, 'example.com') + t.equal(cookies[1].path, '/foo') + + t.equal(cookies[2].name, 'foo') + t.equal(cookies[2].value, sign('foo', secret)) + t.equal(cookies[2].maxAge, 36000) + + t.equal(cookies[3].name, 'foos') + t.equal(cookies[3].value, sign('foosy', secret)) + t.equal(cookies[3].path, '/foo') + t.equal(cookies[3].maxAge, 36000) t.ok(new Date(cookies[1].expires) < new Date()) }) diff --git a/types/plugin.d.ts b/types/plugin.d.ts index 5795beb..23e7500 100644 --- a/types/plugin.d.ts +++ b/types/plugin.d.ts @@ -4,6 +4,15 @@ import { FastifyPluginCallback } from "fastify"; declare module "fastify" { interface FastifyInstance extends SignerMethods { + /** + * Serialize a cookie name-value pair into a Set-Cookie header string + * @param name Cookie name + * @param value Cookie value + * @param opts Options + * @throws {TypeError} When maxAge option is invalid + */ + serializeCookie(name: string, value: string, opts?: fastifyCookie.SerializeOptions): string; + /** * Manual cookie parsing method * @docs https://github.com/fastify/fastify-cookie#manual-cookie-parsing @@ -105,9 +114,10 @@ declare namespace fastifyCookie { unsign: (input: string) => UnsignResult; } - export interface CookieSerializeOptions { + export interface SerializeOptions { /** The `Domain` attribute. */ domain?: string; + /** Specifies a function that will be used to encode a cookie's value. Since value of a cookie has a limited character set (and must be a simple string), this function can be used to encode a value into a string suited for a cookie's value. */ encode?(val: string): string; /** The expiration `date` used for the `Expires` attribute. If both `expires` and `maxAge` are set, then `expires` is used. */ expires?: Date; @@ -121,6 +131,10 @@ declare namespace fastifyCookie { /** A `boolean` or one of the `SameSite` string attributes. E.g.: `lax`, `none` or `strict`. */ sameSite?: 'lax' | 'none' | 'strict' | boolean; /** The `boolean` value of the `Secure` attribute. Set this option to false when communicating over an unencrypted (HTTP) connection. Value can be set to `auto`; in this case the `Secure` attribute will be set to false for HTTP request, in case of HTTPS it will be set to true. Defaults to true. */ + secure?: boolean; + } + + export interface CookieSerializeOptions extends Omit { secure?: boolean | 'auto'; signed?: boolean; } diff --git a/types/plugin.test-d.ts b/types/plugin.test-d.ts index e663ca7..12ac223 100644 --- a/types/plugin.test-d.ts +++ b/types/plugin.test-d.ts @@ -39,6 +39,10 @@ const server = fastify(); server.register(cookie); server.after((_err) => { + expectType< string >( + server.serializeCookie('sessionId', 'aYb4uTIhdBXC') + ); + expectType<{ [key: string]: string }>( // See https://github.com/fastify/fastify-cookie#manual-cookie-parsing server.parseCookie('sessionId=aYb4uTIhdBXC')