116 lines
3.5 KiB
JavaScript
116 lines
3.5 KiB
JavaScript
import session from "express-session";
|
|
import { getPool } from "../db/index.js";
|
|
|
|
const ONE_DAY_MS = 86_400_000;
|
|
const DEFAULT_PRUNE_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
|
// Memory is per-process: a session mutated on another worker (e.g. a logout)
|
|
// can be served stale here for at most one TTL. Keep it short.
|
|
const DEFAULT_MEMORY_TTL_MS = 60 * 1000; // 60 seconds
|
|
|
|
export const expiryOf = (sess) =>
|
|
new Date(sess?.cookie?.expires ?? Date.now() + ONE_DAY_MS);
|
|
|
|
// In-memory cache in front of Postgres: sessions survive restarts and are
|
|
// shared across workers, while the memory map serves the read-heavy hot path.
|
|
export class PgSessionStore extends session.Store {
|
|
constructor({ pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS, memoryTtlMs = DEFAULT_MEMORY_TTL_MS } = {}) {
|
|
super();
|
|
// sid -> { sess, expire (ms epoch), freshUntil (ms epoch) }
|
|
this._mem = new Map();
|
|
this._memoryTtlMs = memoryTtlMs;
|
|
// unref'd so the timer never keeps the process alive.
|
|
if (pruneIntervalMs > 0) {
|
|
this._pruneTimer = setInterval(() => this.prune(), pruneIntervalMs);
|
|
this._pruneTimer.unref?.();
|
|
}
|
|
}
|
|
|
|
async _withPg(fn) {
|
|
const client = await getPool();
|
|
try {
|
|
return await fn(client);
|
|
} finally {
|
|
await client.release();
|
|
}
|
|
}
|
|
|
|
get(sid, cb) {
|
|
const now = Date.now();
|
|
const hit = this._mem.get(sid);
|
|
if (hit) {
|
|
if (hit.expire <= now) {
|
|
this._mem.delete(sid);
|
|
return cb(null, null);
|
|
}
|
|
if (hit.freshUntil > now) return cb(null, hit.sess); // fast path: no DB query
|
|
}
|
|
this._withPg((c) =>
|
|
c.query(`SELECT sess, expire FROM session WHERE sid = $1 AND expire > NOW()`, [sid]),
|
|
)
|
|
.then((res) => {
|
|
const row = res.rows[0];
|
|
if (!row) {
|
|
this._mem.delete(sid);
|
|
return cb(null, null);
|
|
}
|
|
this._mem.set(sid, {
|
|
sess: row.sess,
|
|
expire: new Date(row.expire).getTime(),
|
|
freshUntil: now + this._memoryTtlMs,
|
|
});
|
|
cb(null, row.sess);
|
|
})
|
|
.catch(cb);
|
|
}
|
|
|
|
set(sid, sess, cb = () => {}) {
|
|
const expire = expiryOf(sess);
|
|
this._mem.set(sid, { sess, expire: expire.getTime(), freshUntil: Date.now() + this._memoryTtlMs });
|
|
this._withPg((c) =>
|
|
c.query(
|
|
`INSERT INTO session (sid, sess, expire) VALUES ($1, $2::jsonb, $3)
|
|
ON CONFLICT (sid) DO UPDATE SET sess = EXCLUDED.sess, expire = EXCLUDED.expire, updated_ts = NOW()`,
|
|
[sid, sess, expire],
|
|
),
|
|
)
|
|
.then(() => cb(null))
|
|
.catch(cb);
|
|
}
|
|
|
|
destroy(sid, cb = () => {}) {
|
|
this._mem.delete(sid);
|
|
this._withPg((c) => c.query(`DELETE FROM session WHERE sid = $1`, [sid]))
|
|
.then(() => cb(null))
|
|
.catch(cb);
|
|
}
|
|
|
|
touch(sid, sess, cb = () => {}) {
|
|
const expire = expiryOf(sess);
|
|
const hit = this._mem.get(sid);
|
|
if (hit) {
|
|
hit.expire = expire.getTime();
|
|
hit.freshUntil = Date.now() + this._memoryTtlMs;
|
|
}
|
|
this._withPg((c) =>
|
|
c.query(`UPDATE session SET expire = $2, updated_ts = NOW() WHERE sid = $1`, [sid, expire]),
|
|
)
|
|
.then(() => cb(null))
|
|
.catch(cb);
|
|
}
|
|
|
|
// Errors are swallowed: runs on a timer with no caller.
|
|
async prune() {
|
|
const now = Date.now();
|
|
for (const [sid, v] of this._mem) if (v.expire <= now) this._mem.delete(sid);
|
|
try {
|
|
await this._withPg((c) => c.query(`DELETE FROM session WHERE expire <= NOW()`));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
stopPruning() {
|
|
if (this._pruneTimer) clearInterval(this._pruneTimer);
|
|
}
|
|
}
|