From 6cc6b02e5cb376cbb67416a0e6b18d4809413ffc Mon Sep 17 00:00:00 2001 From: Jimmy li Date: Fri, 17 Jul 2026 21:36:12 +0800 Subject: [PATCH 1/3] feat: add session lifecycle observability hooks Signed-off-by: Jimmy li --- README.md | 25 ++++ index.js | 94 +++++++++----- lib/session.js | 76 ++++++++++- test/observability.test.js | 253 +++++++++++++++++++++++++++++++++++++ types/index.d.ts | 15 +++ types/index.tst.ts | 26 +++- 6 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 test/observability.test.js diff --git a/README.md b/README.md index def847e..af800f0 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,31 @@ Setting this to `false` can save storage space and comply with the EU cookie law ##### rolling (optional) Forces the session identifier cookie to be set on every response. The expiration is reset to the original maxAge - effectively resetting the cookie lifetime. This is typically used in conjunction with short, non-session-length maxAge values to provide a quick expiration of the session data with reduced potential of session expiration occurring during ongoing server interactions. Defaults to true. +##### hooks (optional) +Lifecycle hooks can be used to connect session events to application logging, metrics, or tracing systems. Hooks may be synchronous or asynchronous. Errors thrown by a hook are passed to `onError` and do not interrupt the request. + +```js +app.register(fastifySession, { + secret: process.env.SESSION_SECRET, + hooks: { + onCreate (session, request) { + request.log.info({ sessionId: session.sessionId }, 'session created') + }, + onLoad (session, request) { + request.log.debug({ sessionId: session.sessionId }, 'session loaded') + }, + onSave (session) { + metrics.increment('session.saved') + }, + onError (error, context, request) { + request.log.error({ error, context }, 'session lifecycle error') + } + } +}) +``` + +The available hooks are `onCreate`, `onLoad`, `onLoadMiss`, `onSave`, `onDestroy`, `onRegenerate`, `onExpire`, `onCookieSkipped`, and `onError`. + ##### idGenerator(request) (optional) Function used to generate new session IDs. diff --git a/index.js b/index.js index f4f3c78..8083189 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,31 @@ const idGenerator = require('./lib/idGenerator')() const Store = require('./lib/store') const Session = require('./lib/session') +function createHookEmitter (hooks = {}) { + function reportError (error, context, request) { + if (typeof hooks.onError !== 'function') return + + try { + Promise.resolve(hooks.onError(error, context, request)).catch(() => {}) + } catch {} + } + + function emit (name, args, context, request) { + const hook = hooks[name] + if (typeof hook !== 'function') return + + try { + Promise.resolve(hook(...args)).catch(error => { + reportError(error, { ...context, operation: `hook:${name}` }, request) + }) + } catch (error) { + reportError(error, { ...context, operation: `hook:${name}` }, request) + } + } + + return { emit, reportError } +} + function fastifySession (fastify, options, next) { const error = checkOptions(options) if (error) { @@ -17,6 +42,7 @@ function fastifySession (fastify, options, next) { const cookieSigner = options.signer const cookieName = options.cookieName const cookiePrefix = options.cookiePrefix + const hookEmitter = createHookEmitter(options.hooks) const hasCookiePrefix = typeof cookiePrefix === 'string' && cookiePrefix.length !== 0 const cookiePrefixLength = hasCookiePrefix && cookiePrefix.length @@ -36,19 +62,28 @@ function fastifySession (fastify, options, next) { fastify.addHook('onSend', onSend(options)) next() + function createSession (request, options) { + const session = new Session( + sessionStore, + request, + options.idGenerator, + options.cookie, + cookieSigner, + { hookEmitter } + ) + request.session = session + hookEmitter.emit('onCreate', [session, request], { operation: 'create' }, request) + return session + } + function decryptSession (sessionId, options, request, done) { const cookieOpts = options.cookie const idGenerator = options.idGenerator const unsignedCookie = cookieSigner.unsign(sessionId) if (unsignedCookie.valid === false) { - request.session = new Session( - sessionStore, - request, - idGenerator, - cookieOpts, - cookieSigner - ) + hookEmitter.emit('onLoadMiss', [sessionId, request], { operation: 'load' }, request) + createSession(request, options) done() return } @@ -56,28 +91,19 @@ function fastifySession (fastify, options, next) { sessionStore.get(decryptedSessionId, (err, session) => { if (err) { if (err.code === 'ENOENT') { - request.session = new Session( - sessionStore, - request, - idGenerator, - cookieOpts, - cookieSigner - ) + hookEmitter.emit('onLoadMiss', [decryptedSessionId, request], { operation: 'load', sessionId: decryptedSessionId }, request) + createSession(request, options) done() } else { + hookEmitter.reportError(err, { operation: 'load', sessionId: decryptedSessionId }, request) done(err) } return } if (!session) { - request.session = new Session( - sessionStore, - request, - idGenerator, - cookieOpts, - cookieSigner - ) + hookEmitter.emit('onLoadMiss', [decryptedSessionId, request], { operation: 'load', sessionId: decryptedSessionId }, request) + createSession(request, options) done() return } @@ -88,13 +114,17 @@ function fastifySession (fastify, options, next) { idGenerator, cookieOpts, cookieSigner, - session, - decryptedSessionId + { + prevSession: session, + sessionId: decryptedSessionId, + hookEmitter + } ) const expiration = restoredSession.cookie.originalExpires || restoredSession.cookie.expires if (expiration && expiration.getTime() <= Date.now()) { + hookEmitter.emit('onExpire', [decryptedSessionId, request], { operation: 'expire', sessionId: decryptedSessionId }, request) restoredSession.destroy(err => { if (err) { done(err) @@ -107,6 +137,7 @@ function fastifySession (fastify, options, next) { } request.session = restoredSession + hookEmitter.emit('onLoad', [restoredSession, request], { operation: 'load', sessionId: decryptedSessionId }, request) done() }) } @@ -125,7 +156,6 @@ function fastifySession (fastify, options, next) { function onRequest (options) { const cookieOpts = options.cookie - const idGenerator = options.idGenerator return function handleSession (request, _reply, done) { request.session = {} @@ -138,13 +168,7 @@ function fastifySession (fastify, options, next) { const cookieSessionId = getCookieSessionId(request) if (!cookieSessionId) { - request.session = new Session( - sessionStore, - request, - idGenerator, - cookieOpts, - cookieSigner - ) + createSession(request, options) done() } else { decryptSession(cookieSessionId, options, request, done) @@ -181,6 +205,13 @@ function fastifySession (fastify, options, next) { // we need to remove extra properties to align the same with `express-session` session.cookie.toJSON() ) + } else { + hookEmitter.emit( + 'onCookieSkipped', + [isInsecureConnection ? 'insecure-connection' : 'not-needed', request], + { operation: 'cookie' }, + request + ) } done() @@ -231,6 +262,7 @@ function fastifySession (fastify, options, next) { ? new (require('@fastify/cookie').Signer)(options.secret, opts.algorithm) : options.secret opts.cookiePrefix = option(options, 'cookiePrefix', '') + opts.hooks = options.hooks || {} return opts } diff --git a/lib/session.js b/lib/session.js index 9cbace1..f88b731 100644 --- a/lib/session.js +++ b/lib/session.js @@ -17,6 +17,7 @@ const sessionIdKey = Symbol('sessionId') const sessionStoreKey = Symbol('sessionStore') const encryptedSessionIdKey = Symbol('encryptedSessionId') const savedKey = Symbol('saved') +const hookEmitterKey = Symbol('hookEmitter') module.exports = class Session { constructor ( @@ -25,8 +26,11 @@ module.exports = class Session { idGenerator, cookieOpts, cookieSigner, - prevSession, - sessionId = idGenerator(request) + { + prevSession, + sessionId = idGenerator(request), + hookEmitter + } = {} ) { const previousCookie = prevSession?.cookie && typeof prevSession.cookie.toJSON === 'function' ? prevSession.cookie.toJSON() @@ -56,6 +60,7 @@ module.exports = class Session { this[cookieOptsKey] = sessionCookieOpts this[cookieSignerKey] = cookieSigner this[requestKey] = request + this[hookEmitterKey] = hookEmitter this[sessionIdKey] = sessionId this[encryptedSessionIdKey] = ( prevSession && @@ -102,7 +107,8 @@ module.exports = class Session { this[requestKey], this[generateId], this[cookieOptsKey], - this[cookieSignerKey] + this[cookieSignerKey], + { hookEmitter: this[hookEmitterKey] } ) if (Array.isArray(keys)) { @@ -115,6 +121,17 @@ module.exports = class Session { this[sessionStoreKey].set(session.sessionId, session, error => { this[requestKey].session = session + if (!error) { + this[hookEmitterKey]?.emit( + 'onRegenerate', + [this[sessionIdKey], session.sessionId, this[requestKey]], + { operation: 'regenerate', sessionId: this[sessionIdKey] }, + this[requestKey] + ) + } else { + this[hookEmitterKey]?.reportError(error, { operation: 'regenerate', sessionId: this[sessionIdKey] }, this[requestKey]) + } + callback(error) }) } else { @@ -123,8 +140,15 @@ module.exports = class Session { this[requestKey].session = session if (error) { + this[hookEmitterKey]?.reportError(error, { operation: 'regenerate', sessionId: this[sessionIdKey] }, this[requestKey]) reject(error) } else { + this[hookEmitterKey]?.emit( + 'onRegenerate', + [this[sessionIdKey], session.sessionId, this[requestKey]], + { operation: 'regenerate', sessionId: this[sessionIdKey] }, + this[requestKey] + ) resolve() } }) @@ -145,6 +169,17 @@ module.exports = class Session { this[sessionStoreKey].destroy(this[sessionIdKey], error => { this[requestKey].session = null + if (error) { + this[hookEmitterKey]?.reportError(error, { operation: 'destroy', sessionId: this[sessionIdKey] }, this[requestKey]) + } else { + this[hookEmitterKey]?.emit( + 'onDestroy', + [this[sessionIdKey], this[requestKey]], + { operation: 'destroy', sessionId: this[sessionIdKey] }, + this[requestKey] + ) + } + callback(error) }) } else { @@ -153,8 +188,15 @@ module.exports = class Session { this[requestKey].session = null if (error) { + this[hookEmitterKey]?.reportError(error, { operation: 'destroy', sessionId: this[sessionIdKey] }, this[requestKey]) reject(error) } else { + this[hookEmitterKey]?.emit( + 'onDestroy', + [this[sessionIdKey], this[requestKey]], + { operation: 'destroy', sessionId: this[sessionIdKey] }, + this[requestKey] + ) resolve() } }) @@ -170,8 +212,11 @@ module.exports = class Session { this[generateId], this[cookieOptsKey], this[cookieSignerKey], - session, - this[sessionIdKey] + { + prevSession: session, + sessionId: this[sessionIdKey], + hookEmitter: this[hookEmitterKey] + } ) callback(error) @@ -185,8 +230,11 @@ module.exports = class Session { this[generateId], this[cookieOptsKey], this[cookieSignerKey], - session, - this[sessionIdKey] + { + prevSession: session, + sessionId: this[sessionIdKey], + hookEmitter: this[hookEmitterKey] + } ) if (error) { @@ -203,10 +251,17 @@ module.exports = class Session { if (callback) { this[sessionStoreKey].set(this[sessionIdKey], this, error => { if (error) { + this[hookEmitterKey]?.reportError(error, { operation: 'save', sessionId: this[sessionIdKey] }, this[requestKey]) callback(error) } else { this[savedKey] = true this[persistedHash] = this[hash]() + this[hookEmitterKey]?.emit( + 'onSave', + [this, this[requestKey]], + { operation: 'save', sessionId: this[sessionIdKey] }, + this[requestKey] + ) callback() } }) @@ -214,10 +269,17 @@ module.exports = class Session { return new Promise((resolve, reject) => { this[sessionStoreKey].set(this[sessionIdKey], this, error => { if (error) { + this[hookEmitterKey]?.reportError(error, { operation: 'save', sessionId: this[sessionIdKey] }, this[requestKey]) reject(error) } else { this[savedKey] = true this[persistedHash] = this[hash]() + this[hookEmitterKey]?.emit( + 'onSave', + [this, this[requestKey]], + { operation: 'save', sessionId: this[sessionIdKey] }, + this[requestKey] + ) resolve() } }) diff --git a/test/observability.test.js b/test/observability.test.js new file mode 100644 index 0000000..64118fd --- /dev/null +++ b/test/observability.test.js @@ -0,0 +1,253 @@ +'use strict' + +const test = require('node:test') +const { buildFastify, DEFAULT_COOKIE, DEFAULT_OPTIONS, DEFAULT_SECRET } = require('./util') + +test('emits create and save hooks for a new session', async t => { + const events = [] + const fastify = await buildFastify((request, reply) => { + request.session.set('userId', 'user-1') + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onCreate: () => events.push('create'), + onSave: () => events.push('save') + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(events, ['create', 'save']) +}) + +test('emits load and load miss hooks', async t => { + const events = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onCreate: () => events.push('create'), + onLoad: () => events.push('load'), + onLoadMiss: () => events.push('load-miss') + } + }) + t.after(() => fastify.close()) + + const firstResponse = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: DEFAULT_COOKIE } + }) + + t.assert.strictEqual(firstResponse.statusCode, 200) + t.assert.deepStrictEqual(events, ['load-miss', 'create']) + + events.length = 0 + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: firstResponse.headers['set-cookie'] } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(events, ['load']) +}) + +test('emits destroy and regenerate hooks', async t => { + const events = [] + const fastify = await buildFastify(async (request, reply) => { + await request.session.regenerate() + await request.session.destroy() + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onRegenerate: () => events.push('regenerate'), + onDestroy: () => events.push('destroy') + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(events, ['regenerate', 'destroy']) +}) + +test('emits cookie skipped when a session does not need saving', async t => { + const reasons = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + cookie: { secure: false }, + saveUninitialized: false, + hooks: { + onCookieSkipped: reason => reasons.push(reason) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(reasons, ['not-needed']) +}) + +test('emits expire hook for an expired session', async t => { + const events = [] + const store = { + set (_sessionId, _session, callback) { + callback() + }, + get (_sessionId, callback) { + callback(null, { + cookie: { + expires: new Date(Date.now() - 1000) + } + }) + }, + destroy (_sessionId, callback) { + callback() + } + } + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + store, + cookie: { secure: false }, + hooks: { + onExpire: () => events.push('expire') + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: DEFAULT_COOKIE } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(events, ['expire']) +}) + +test('emits error hook when the store fails', async t => { + const errors = [] + const store = { + set (_sessionId, _session, callback) { + callback(new Error('store.set')) + }, + get (_sessionId, callback) { + callback(null, null) + }, + destroy (_sessionId, callback) { + callback() + } + } + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + store, + cookie: { secure: false }, + hooks: { + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 500) + t.assert.ok(errors.length > 0) + t.assert.strictEqual(errors[0].error.message, 'store.set') + t.assert.ok(errors.every(({ context }) => context.operation === 'save')) +}) + +test('reports synchronous and asynchronous hook errors without failing the request', async t => { + const errors = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onCreate () { + throw new Error('sync hook') + }, + onSave: async () => { + throw new Error('async hook') + }, + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + await new Promise(resolve => setImmediate(resolve)) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(errors.map(({ error }) => error.message), ['sync hook', 'async hook']) + t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['hook:onCreate', 'hook:onSave']) +}) + +test('reports callback errors from regenerate', async t => { + const errors = [] + const store = { + set (_sessionId, _session, callback) { + callback(new Error('store.set')) + }, + get (_sessionId, callback) { + callback(null, null) + }, + destroy (_sessionId, callback) { + callback() + } + } + const fastify = await buildFastify((request, reply) => { + request.session.regenerate(error => { + request.session = null + reply.send({ error: error.message }) + }) + }, { + secret: DEFAULT_SECRET, + store, + cookie: { secure: false }, + hooks: { + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['regenerate']) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 35729f0..0acf886 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -116,6 +116,18 @@ declare namespace fastifySession { destroy(sessionId: string, callback: Callback): void; } + export interface SessionLifecycleHooks { + onCreate?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; + onLoad?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; + onLoadMiss?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; + onSave?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; + onDestroy?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; + onRegenerate?(oldSessionId: string, newSessionId: string, request: Fastify.FastifyRequest): void | Promise; + onExpire?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; + onCookieSkipped?(reason: 'not-needed' | 'insecure-connection', request: Fastify.FastifyRequest): void | Promise; + onError?(error: Error, context: { operation: string; sessionId?: string }, request: Fastify.FastifyRequest): void | Promise; + } + export interface FastifySessionOptions { /** * The secret used to sign the cookie. @@ -178,6 +190,9 @@ declare namespace fastifySession { * Defaults to "" */ cookiePrefix?: string; + + /** Optional hooks for observing session lifecycle events. */ + hooks?: SessionLifecycleHooks; } export interface CookieOptions extends Omit { diff --git a/types/index.tst.ts b/types/index.tst.ts index 593601b..057e74a 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -8,7 +8,7 @@ import fastify, { } from 'fastify' import Redis from 'ioredis' import { expect } from 'tstyche' -import fastifySession, { type CookieOptions, MemoryStore, type SessionStore } from '..' +import fastifySession, { type CookieOptions, MemoryStore, type SessionLifecycleHooks, type SessionStore } from '..' const plugin = fastifySession @@ -79,6 +79,30 @@ app.register(plugin, { app.register(plugin, { secret, }) +const lifecycleHooks: SessionLifecycleHooks = { + onCreate: (session, request) => { + request.log.debug({ sessionId: session.sessionId }, 'session created') + }, + onLoad: async (session) => { + session.get('foo') + }, + onLoadMiss: (sessionId) => { + sessionId.toUpperCase() + }, + onSave: () => {}, + onDestroy: () => {}, + onRegenerate: () => {}, + onExpire: () => {}, + onCookieSkipped: (reason) => { + expect(reason).type.toBe<'not-needed' | 'insecure-connection'>() + }, + onError: (error, context, request) => { + expect(error.message).type.toBe() + expect(context.operation).type.toBe() + request.log.error(error) + } +} +app.register(plugin, { secret, hooks: lifecycleHooks }) app.register(plugin, { secret, idGenerator: (request) => `${request === undefined ? 'null' : request.ip}-${Date.now()}` From 8377d43362b9470926d7dcf9a984d5c4bffaba63 Mon Sep 17 00:00:00 2001 From: Jimmy li Date: Wed, 22 Jul 2026 15:08:41 +0800 Subject: [PATCH 2/3] test: strengthen session hook coverage Signed-off-by: Jimmy li --- test/observability.test.js | 490 +++++++++++++++++++++++++++++++++++-- 1 file changed, 474 insertions(+), 16 deletions(-) diff --git a/test/observability.test.js b/test/observability.test.js index 64118fd..be26a61 100644 --- a/test/observability.test.js +++ b/test/observability.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('node:test') -const { buildFastify, DEFAULT_COOKIE, DEFAULT_OPTIONS, DEFAULT_SECRET } = require('./util') +const { buildFastify, DEFAULT_COOKIE, DEFAULT_OPTIONS, DEFAULT_SECRET, DEFAULT_SESSION_ID } = require('./util') test('emits create and save hooks for a new session', async t => { const events = [] @@ -12,8 +12,8 @@ test('emits create and save hooks for a new session', async t => { ...DEFAULT_OPTIONS, cookie: { secure: false }, hooks: { - onCreate: () => events.push('create'), - onSave: () => events.push('save') + onCreate: (session, request) => events.push({ name: 'create', session, request }), + onSave: (session, request) => events.push({ name: 'save', session, request }) } }) t.after(() => fastify.close()) @@ -24,7 +24,10 @@ test('emits create and save hooks for a new session', async t => { }) t.assert.strictEqual(response.statusCode, 200) - t.assert.deepStrictEqual(events, ['create', 'save']) + t.assert.deepStrictEqual(events.map(event => event.name), ['create', 'save']) + t.assert.strictEqual(events[0].request.session, events[0].session) + t.assert.strictEqual(events[1].request.session, events[1].session) + t.assert.strictEqual(events[1].session.isSaved(), true) }) test('emits load and load miss hooks', async t => { @@ -35,9 +38,9 @@ test('emits load and load miss hooks', async t => { ...DEFAULT_OPTIONS, cookie: { secure: false }, hooks: { - onCreate: () => events.push('create'), - onLoad: () => events.push('load'), - onLoadMiss: () => events.push('load-miss') + onCreate: (session, request) => events.push({ name: 'create', session, request }), + onLoad: (session, request) => events.push({ name: 'load', session, request }), + onLoadMiss: (sessionId, request) => events.push({ name: 'load-miss', sessionId, request }) } }) t.after(() => fastify.close()) @@ -49,7 +52,11 @@ test('emits load and load miss hooks', async t => { }) t.assert.strictEqual(firstResponse.statusCode, 200) - t.assert.deepStrictEqual(events, ['load-miss', 'create']) + t.assert.deepStrictEqual(events.map(event => event.name), ['load-miss', 'create']) + t.assert.strictEqual(events[0].sessionId, DEFAULT_SESSION_ID) + t.assert.strictEqual(events[0].request.raw.url, '/') + t.assert.strictEqual(events[1].request.session, events[1].session) + const createdSessionId = events[1].session.sessionId events.length = 0 const response = await fastify.inject({ @@ -59,21 +66,242 @@ test('emits load and load miss hooks', async t => { }) t.assert.strictEqual(response.statusCode, 200) - t.assert.deepStrictEqual(events, ['load']) + t.assert.deepStrictEqual(events.map(event => event.name), ['load']) + t.assert.strictEqual(events[0].session.sessionId, createdSessionId) + t.assert.strictEqual(events[0].request.session, events[0].session) +}) + +test('passes an invalid cookie value to the load miss hook', async t => { + const events = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onLoadMiss: (sessionId, request) => events.push({ sessionId, request }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: 'sessionId=invalid' } + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(events.length, 1) + t.assert.strictEqual(events[0].sessionId, 'invalid') + t.assert.strictEqual(events[0].request.raw.url, '/') }) test('emits destroy and regenerate hooks', async t => { const events = [] + const fastify = await buildFastify(async (request, reply) => { + const originalSessionId = request.session.sessionId + await request.session.regenerate() + await request.session.destroy() + reply.send({ originalSessionId }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onRegenerate: (oldId, newId, request) => events.push({ + name: 'regenerate', + oldId, + newId, + requestSessionId: request.session.sessionId + }), + onDestroy: (sessionId, request) => events.push({ + name: 'destroy', + sessionId, + requestSession: request.session + }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(events.map(event => event.name), ['regenerate', 'destroy']) + t.assert.notStrictEqual(events[0].oldId, events[0].newId) + t.assert.strictEqual(events[0].requestSessionId, events[0].newId) + t.assert.strictEqual(events[1].sessionId, events[0].newId) + t.assert.strictEqual(events[1].requestSession, null) +}) + +test('emits callback lifecycle hook payloads', async t => { + const events = [] + const errors = [] + const fastify = await buildFastify((request, reply) => { + const originalSessionId = request.session.sessionId + request.session.regenerate(error => { + if (error) return reply.send(error) + + const regeneratedSessionId = request.session.sessionId + request.session.destroy(error => { + if (error) return reply.send(error) + reply.send({ originalSessionId, regeneratedSessionId }) + }) + }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onRegenerate: (oldId, newId, request) => { + events.push({ + name: 'regenerate', + oldId, + newId, + requestSessionId: request.session.sessionId + }) + throw new Error('regenerate hook') + }, + onDestroy: (sessionId, request) => { + events.push({ + name: 'destroy', + sessionId, + requestSession: request.session + }) + throw new Error('destroy hook') + }, + onError: (error, context, request) => errors.push({ error, context, request }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + await new Promise(resolve => setImmediate(resolve)) + t.assert.deepStrictEqual(events.map(event => event.name), ['regenerate', 'destroy']) + t.assert.notStrictEqual(events[0].oldId, events[0].newId) + t.assert.strictEqual(events[0].requestSessionId, events[0].newId) + t.assert.strictEqual(events[1].sessionId, events[0].newId) + t.assert.strictEqual(events[1].requestSession, null) + t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['hook:onRegenerate', 'hook:onDestroy']) + t.assert.strictEqual(errors[0].context.sessionId, events[0].oldId) + t.assert.strictEqual(errors[1].context.sessionId, events[0].newId) +}) + +test('emits the save hook for the promise API', async t => { + const events = [] + const fastify = await buildFastify(async (request, reply) => { + request.session.set('userId', 'user-1') + await request.session.save() + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + rolling: false, + saveUninitialized: false, + hooks: { + onSave: (session, request) => events.push({ + session, + request, + requestSession: request.session, + saved: session.isSaved() + }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(events.length, 1) + t.assert.strictEqual(events[0].requestSession, events[0].session) + t.assert.strictEqual(events[0].request.session, events[0].session) + t.assert.strictEqual(events[0].saved, true) +}) + +test('reports context from promise lifecycle hook failures', async t => { + const errors = [] const fastify = await buildFastify(async (request, reply) => { await request.session.regenerate() + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onRegenerate: () => { + throw new Error('regenerate hook') + }, + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + await new Promise(resolve => setImmediate(resolve)) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(errors.length, 1) + t.assert.strictEqual(errors[0].error.message, 'regenerate hook') + t.assert.strictEqual(errors[0].context.operation, 'hook:onRegenerate') + t.assert.ok(errors[0].context.sessionId) +}) + +test('reports context from a promise destroy hook failure', async t => { + const errors = [] + const fastify = await buildFastify(async (request, reply) => { await request.session.destroy() reply.send({ ok: true }) }, { ...DEFAULT_OPTIONS, cookie: { secure: false }, hooks: { - onRegenerate: () => events.push('regenerate'), - onDestroy: () => events.push('destroy') + onDestroy: () => { + throw new Error('destroy hook') + }, + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + await new Promise(resolve => setImmediate(resolve)) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(errors.length, 1) + t.assert.strictEqual(errors[0].error.message, 'destroy hook') + t.assert.strictEqual(errors[0].context.operation, 'hook:onDestroy') + t.assert.ok(errors[0].context.sessionId) +}) + +test('reports context from a promise save hook failure', async t => { + const errors = [] + const fastify = await buildFastify(async (request, reply) => { + request.session.set('userId', 'user-1') + await request.session.save() + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + rolling: false, + saveUninitialized: false, + hooks: { + onSave: () => { + throw new Error('save hook') + }, + onError: (error, context) => errors.push({ error, context }) } }) t.after(() => fastify.close()) @@ -82,9 +310,52 @@ test('emits destroy and regenerate hooks', async t => { method: 'GET', url: '/' }) + await new Promise(resolve => setImmediate(resolve)) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(errors.length, 1) + t.assert.strictEqual(errors[0].error.message, 'save hook') + t.assert.strictEqual(errors[0].context.operation, 'hook:onSave') + t.assert.ok(errors[0].context.sessionId) +}) + +test('reports context from load hook failures', async t => { + const errors = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + ...DEFAULT_OPTIONS, + cookie: { secure: false }, + hooks: { + onLoadMiss: () => { + throw new Error('load miss hook') + }, + onLoad: () => { + throw new Error('load hook') + }, + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const firstResponse = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: DEFAULT_COOKIE } + }) + await new Promise(resolve => setImmediate(resolve)) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: firstResponse.headers['set-cookie'] } + }) + await new Promise(resolve => setImmediate(resolve)) t.assert.strictEqual(response.statusCode, 200) - t.assert.deepStrictEqual(events, ['regenerate', 'destroy']) + t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['hook:onLoadMiss', 'hook:onLoad']) + t.assert.strictEqual(errors[0].context.sessionId, DEFAULT_SESSION_ID) + t.assert.ok(errors[1].context.sessionId) }) test('emits cookie skipped when a session does not need saving', async t => { @@ -110,8 +381,31 @@ test('emits cookie skipped when a session does not need saving', async t => { t.assert.deepStrictEqual(reasons, ['not-needed']) }) +test('emits insecure connection when cookie saving is skipped', async t => { + const reasons = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + saveUninitialized: false, + hooks: { + onCookieSkipped: reason => reasons.push(reason) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(reasons, ['insecure-connection']) +}) + test('emits expire hook for an expired session', async t => { const events = [] + const errors = [] const store = { set (_sessionId, _session, callback) { callback() @@ -134,7 +428,11 @@ test('emits expire hook for an expired session', async t => { store, cookie: { secure: false }, hooks: { - onExpire: () => events.push('expire') + onExpire: (sessionId, request) => { + events.push({ sessionId, request }) + throw new Error('expire hook') + }, + onError: (error, context) => errors.push({ error, context }) } }) t.after(() => fastify.close()) @@ -146,7 +444,13 @@ test('emits expire hook for an expired session', async t => { }) t.assert.strictEqual(response.statusCode, 200) - t.assert.deepStrictEqual(events, ['expire']) + await new Promise(resolve => setImmediate(resolve)) + t.assert.strictEqual(events.length, 1) + t.assert.strictEqual(events[0].sessionId, DEFAULT_SESSION_ID) + t.assert.strictEqual(events[0].request.raw.url, '/') + t.assert.strictEqual(errors[0].error.message, 'expire hook') + t.assert.strictEqual(errors[0].context.operation, 'hook:onExpire') + t.assert.strictEqual(errors[0].context.sessionId, DEFAULT_SESSION_ID) }) test('emits error hook when the store fails', async t => { @@ -169,7 +473,7 @@ test('emits error hook when the store fails', async t => { store, cookie: { secure: false }, hooks: { - onError: (error, context) => errors.push({ error, context }) + onError: (error, context, request) => errors.push({ error, context, request }) } }) t.after(() => fastify.close()) @@ -183,6 +487,159 @@ test('emits error hook when the store fails', async t => { t.assert.ok(errors.length > 0) t.assert.strictEqual(errors[0].error.message, 'store.set') t.assert.ok(errors.every(({ context }) => context.operation === 'save')) + t.assert.ok(errors.every(({ context, request }) => context.sessionId === request.session.sessionId)) +}) + +test('reports store errors from promise session APIs', async t => { + const errors = [] + let setCalls = 0 + const fastify = await buildFastify(async (request, reply) => { + request.session.set('userId', 'user-1') + try { + await request.session.save() + } catch {} + try { + await request.session.destroy() + } catch {} + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + rolling: false, + saveUninitialized: false, + cookie: { secure: false }, + store: { + set (_sessionId, _session, callback) { + setCalls++ + callback(setCalls === 1 ? new Error('store.save') : undefined) + }, + get (_sessionId, callback) { + callback(null, null) + }, + destroy (_sessionId, callback) { + callback(new Error('store.destroy')) + } + }, + hooks: { + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.deepStrictEqual(errors.map(({ error }) => error.message), ['store.save', 'store.destroy']) + t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['save', 'destroy']) + t.assert.ok(errors.every(({ context }) => context.sessionId)) +}) + +test('reports errors when loading a session from the store fails', async t => { + const errors = [] + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + cookie: { secure: false }, + store: { + set (_sessionId, _session, callback) { + callback() + }, + get (_sessionId, callback) { + callback(new Error('store.get')) + }, + destroy (_sessionId, callback) { + callback() + } + }, + hooks: { + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { cookie: DEFAULT_COOKIE } + }) + + t.assert.strictEqual(response.statusCode, 500) + t.assert.strictEqual(errors[0].error.message, 'store.get') + t.assert.strictEqual(errors[0].context.operation, 'load') + t.assert.strictEqual(errors[0].context.sessionId, DEFAULT_SESSION_ID) +}) + +test('reports destroy errors from the callback API', async t => { + const errors = [] + const fastify = await buildFastify((request, reply) => { + request.session.destroy(error => { + reply.send({ error: error.message }) + }) + }, { + secret: DEFAULT_SECRET, + cookie: { secure: false }, + store: { + set (_sessionId, _session, callback) { + callback() + }, + get (_sessionId, callback) { + callback(null, null) + }, + destroy (_sessionId, callback) { + callback(new Error('store.destroy')) + } + }, + hooks: { + onError: (error, context) => errors.push({ error, context }) + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 200) + t.assert.strictEqual(errors[0].error.message, 'store.destroy') + t.assert.strictEqual(errors[0].context.operation, 'destroy') + t.assert.ok(errors[0].context.sessionId) +}) + +test('ignores errors thrown by the error hook', async t => { + const fastify = await buildFastify((_request, reply) => { + reply.send({ ok: true }) + }, { + secret: DEFAULT_SECRET, + cookie: { secure: false }, + store: { + set (_sessionId, _session, callback) { + callback(new Error('store.set')) + }, + get (_sessionId, callback) { + callback(null, null) + }, + destroy (_sessionId, callback) { + callback() + } + }, + hooks: { + onError () { + throw new Error('onError failed') + } + } + }) + t.after(() => fastify.close()) + + const response = await fastify.inject({ + method: 'GET', + url: '/' + }) + + t.assert.strictEqual(response.statusCode, 500) }) test('reports synchronous and asynchronous hook errors without failing the request', async t => { @@ -199,7 +656,7 @@ test('reports synchronous and asynchronous hook errors without failing the reque onSave: async () => { throw new Error('async hook') }, - onError: (error, context) => errors.push({ error, context }) + onError: (error, context, request) => errors.push({ error, context, request }) } }) t.after(() => fastify.close()) @@ -213,6 +670,7 @@ test('reports synchronous and asynchronous hook errors without failing the reque t.assert.strictEqual(response.statusCode, 200) t.assert.deepStrictEqual(errors.map(({ error }) => error.message), ['sync hook', 'async hook']) t.assert.deepStrictEqual(errors.map(({ context }) => context.operation), ['hook:onCreate', 'hook:onSave']) + t.assert.strictEqual(errors[1].context.sessionId, errors[1].request.session.sessionId) }) test('reports callback errors from regenerate', async t => { From 7741e89e9ce91aa62905dd3d144babf3f53e096e Mon Sep 17 00:00:00 2001 From: Jimmy li Date: Wed, 22 Jul 2026 15:36:50 +0800 Subject: [PATCH 3/3] fix: align lifecycle hook types with runtime Signed-off-by: Jimmy li --- types/index.d.ts | 42 ++++++++++++++++++++++++++---------------- types/index.tst.ts | 25 +++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/types/index.d.ts b/types/index.d.ts index 0acf886..6a63f29 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -21,10 +21,11 @@ declare module 'fastify' { interface Session extends ExpressSessionData { } } -type FastifySession = FastifyPluginCallback & { - Store: fastifySession.MemoryStore, - MemoryStore: fastifySession.MemoryStore, -} +type FastifySession = FastifyPluginCallback & + FastifyPluginCallback> & { + Store: fastifySession.MemoryStore, + MemoryStore: fastifySession.MemoryStore, + } type Callback = (err?: any) => void type CallbackSession = (err: any, result?: Fastify.Session | null) => void @@ -116,19 +117,28 @@ declare namespace fastifySession { destroy(sessionId: string, callback: Callback): void; } - export interface SessionLifecycleHooks { - onCreate?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; - onLoad?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; - onLoadMiss?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; - onSave?(session: FastifySessionObject, request: Fastify.FastifyRequest): void | Promise; - onDestroy?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; - onRegenerate?(oldSessionId: string, newSessionId: string, request: Fastify.FastifyRequest): void | Promise; - onExpire?(sessionId: string, request: Fastify.FastifyRequest): void | Promise; - onCookieSkipped?(reason: 'not-needed' | 'insecure-connection', request: Fastify.FastifyRequest): void | Promise; - onError?(error: Error, context: { operation: string; sessionId?: string }, request: Fastify.FastifyRequest): void | Promise; + export interface SessionLifecycleContext { + operation: string; + sessionId?: string; + } + + type SessionLifecycleHook = { + bivarianceHack(...args: [...Arguments, request: Request]): void | Promise; + }['bivarianceHack'] + + export interface SessionLifecycleHooks = Fastify.FastifyRequest> { + onCreate?: SessionLifecycleHook; + onLoad?: SessionLifecycleHook; + onLoadMiss?: SessionLifecycleHook; + onSave?: SessionLifecycleHook; + onDestroy?: SessionLifecycleHook; + onRegenerate?: SessionLifecycleHook; + onExpire?: SessionLifecycleHook; + onCookieSkipped?: SessionLifecycleHook; + onError?: SessionLifecycleHook; } - export interface FastifySessionOptions { + export interface FastifySessionOptions = Fastify.FastifyRequest> { /** * The secret used to sign the cookie. * @@ -192,7 +202,7 @@ declare namespace fastifySession { cookiePrefix?: string; /** Optional hooks for observing session lifecycle events. */ - hooks?: SessionLifecycleHooks; + hooks?: SessionLifecycleHooks; } export interface CookieOptions extends Omit { diff --git a/types/index.tst.ts b/types/index.tst.ts index 057e74a..d293074 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -8,7 +8,7 @@ import fastify, { } from 'fastify' import Redis from 'ioredis' import { expect } from 'tstyche' -import fastifySession, { type CookieOptions, MemoryStore, type SessionLifecycleHooks, type SessionStore } from '..' +import fastifySession, { type CookieOptions, MemoryStore, type FastifySessionOptions, type SessionLifecycleContext, type SessionLifecycleHooks, type SessionStore } from '..' const plugin = fastifySession @@ -97,12 +97,33 @@ const lifecycleHooks: SessionLifecycleHooks = { expect(reason).type.toBe<'not-needed' | 'insecure-connection'>() }, onError: (error, context, request) => { - expect(error.message).type.toBe() + expect(error).type.toBe() expect(context.operation).type.toBe() request.log.error(error) } } app.register(plugin, { secret, hooks: lifecycleHooks }) + +const lifecycleContext: SessionLifecycleContext = { operation: 'load', sessionId: 'session-id' } +expect(lifecycleContext.operation).type.toBe() + +type TraceRequest = { traceId: string } +const customRequestHooks: SessionLifecycleHooks = { + onCreate: (_session, request) => { + expect(request.traceId).type.toBe() + }, + onError: (error, context, request) => { + expect(error).type.toBe() + expect(context.sessionId).type.toBe() + expect(request.traceId).type.toBe() + } +} +const customRequestOptions: FastifySessionOptions = { + secret, + hooks: customRequestHooks +} +expect(customRequestOptions.hooks).type.toBe | undefined>() +app.register(plugin, customRequestOptions) app.register(plugin, { secret, idGenerator: (request) => `${request === undefined ? 'null' : request.ip}-${Date.now()}`