@pwrdrvr/dynamodb-session-store
Version:
express-session library for DynamoDB with configurable `touch` write reductions and configurable eventually consistent reads for higher reliability/throughput and lower costs
443 lines • 16.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DynamoDBStore = void 0;
const express_session_1 = require("express-session");
const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
const debug_1 = __importDefault(require("debug"));
const util_1 = require("util");
const deep_replace_dates_with_strings_1 = require("./deep-replace-dates-with-strings");
const sleep = (0, util_1.promisify)(setTimeout);
const debug = (0, debug_1.default)('@pwrdrvr/dynamodb-session-store');
/**
* DynamoDBStore is an [express-session](https://www.npmjs.com/package/express-session) store that uses
* [DynamoDB](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html)
* as the backing store.
*
* @remarks
*
* DynamoDB is an excellent choice for session stores because it is
* a fully managed service that is highly available, durable, and
* can scale automatically (to nearly unlimited levels) to meet demand.
*
* DynamoDB reads will typically return in 1-3 ms if capacity is set
* correctly and the caller is located in the same region as the `Table`.
*
* ### Example of Pricing
*
* Disclaimer: perform your own pricing calculation, monitor your costs
* while launching, and setup cost alerts to avoid unexpected charges.
*
* [Saved AWS Pricing Calculation](https://calculator.aws/#/estimate?id=fb2f0d461ab2acd6c98a107059f75a4325918bda)
*
* Assumptions:
* - Using Provisioned Capacity with auto-scaling
* - Using Eventually Consistent Reads
* - 2 KB average session size
* - 100k RPM (requests per minute) average load
* - 1 million new sessions per month (~0.4 new sessions / second)
* - 8 million existing sessions
* - 2 million session updates / expirations per month (~0.8 updates / second)
*
* Pricing:
* - Storage
* - 2 KB * 8 million = 16 GB of storage
* - 16 GB * $0.25 / GB / month = $4 / month for storage
* - Reads
* - 100k RPM / 60 seconds = ~1,700 RPS (requests per second)
* - 1 RCU (read capacity unit) per item * 0.5 (eventually consistent reads) = 0.5 RCU per read
* - 1,700 RPS * 0.5 RCU per read = 850 RCUs
* - 850 RCUs / read * 720 hours / month * $0.00013 / RCU / hour = ~$80 / month for reads
* - Writes
* - 0.4 new sessions / second + 0.8 updates / second = 1.2 WPS (writes per second)
* - 1.2 WPS * 2 WCU (write capacity unit) per item = 2.4 WCUs
* - Allocate more WCUs to handle bursts
* - 100 WCUs * 720 hours / month * $0.00065 / WCU / hour = ~$50 / month for writes
* - Total
* - $4 / month for storage
* - $80 / month for reads
* - $50 / month for writes
* - $134 / month total
*/
class DynamoDBStore extends express_session_1.Store {
/**
* { @inheritDoc DynamoDBStoreOptions.tableName }
*/
get tableName() {
return this._tableName;
}
/**
* { @inheritDoc DynamoDBStoreOptions.touchAfter }
*/
get touchAfter() {
return this._touchAfter;
}
/**
* { @inheritDoc DynamoDBStoreOptions.useStronglyConsistentReads }
*/
get useStronglyConsistentReads() {
return this._useStronglyConsistentReads;
}
set useStronglyConsistentReads(value) {
this._useStronglyConsistentReads = value;
}
/**
* { @inheritDoc DynamoDBStoreOptions.hashKey }
*/
get hashKey() {
return this._hashKey;
}
/**
* { @inheritDoc DynamoDBStoreOptions.prefix }
*/
get prefix() {
return this._prefix;
}
/**
* Create the table if it does not exist
* Enable TTL field on the table if configured
*
* @remarks
* ⛔️ NOT SUGGESTED ⛔️: This is not recommended for production use.
*
* For production the table should be created with IaaC (infrastructure as code)
* such as AWS CDK, SAM, CloudFormation, Terraform, etc.
*/
async createTableIfNotExists() {
try {
const describeTable = await this._dynamoDBClient.send(new client_dynamodb_1.DescribeTableCommand({
TableName: this._tableName,
}));
if (describeTable.Table) {
debug('table %s already exists', this._tableName);
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (error) {
debug('table %s does not exist: %s', this._tableName, error.message);
}
const params = {
TableName: this._tableName,
AttributeDefinitions: [
{
AttributeName: this._hashKey,
AttributeType: 'S',
},
],
KeySchema: [
{
AttributeName: this._hashKey,
KeyType: 'HASH',
},
],
...this._createTableOptions,
};
debug('creating table %s, with params: %O', this._tableName, params);
try {
await this._dynamoDBClient.send(new client_dynamodb_1.CreateTableCommand(params));
// Wait until the table is active
let tableReady = false;
for (let i = 0; i < 10; i++) {
try {
const describeTable = await this._dynamoDBClient.send(new client_dynamodb_1.DescribeTableCommand({
TableName: this._tableName,
}));
if (describeTable.Table && describeTable.Table.TableStatus === 'ACTIVE') {
debug('table %s created', this._tableName);
tableReady = true;
break;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (error) {
debug('table %s not active yet: %s', this._tableName, error.message);
}
// Wait a bit before we check if the table is ready again
await sleep(3000);
}
if (!tableReady) {
debug('table not ready, returning %s', this._tableName, params);
return;
}
// Create the TTL Config
// This is probably really something that should be done in IaaC instead
await this._dynamoDBClient.send(new client_dynamodb_1.UpdateTimeToLiveCommand({
TableName: this._tableName,
TimeToLiveSpecification: {
AttributeName: 'expires',
Enabled: true,
},
}));
debug('created table %s', this._tableName, params);
}
catch (err) {
debug('error creating table %s: %s', this._tableName, err);
throw err;
}
}
/**
* Create a DynamoDB Table-based [express-session](https://www.npmjs.com/package/express-session) store.
*
* @remarks
* ⛔️ NOT SUGGESTED ⛔️: `createTableOptions` is not recommended for production use.
*
* Note: This does not await creation of a table if `createTableOptions` is passed (which should only
* be used in quick and dirty tests). Use `DynamoDBStore.create()` instead to await
* creation of the table in testing scenarios.
*/
constructor(options) {
super();
const { dynamoDBClient = new client_dynamodb_1.DynamoDBClient({}), tableName = 'sessions', createTableOptions, hashKey = 'id', useStronglyConsistentReads = false, prefix = 'session#', touchAfter = 3600, } = options;
this._prefix = prefix;
this._dynamoDBClient = dynamoDBClient;
this._ddbDocClient = lib_dynamodb_1.DynamoDBDocument.from(dynamoDBClient, {
marshallOptions: { removeUndefinedValues: true, convertClassInstanceToMap: true },
});
this._tableName = tableName;
this._touchAfter = touchAfter;
this._createTableOptions = createTableOptions;
this._hashKey = hashKey;
this._useStronglyConsistentReads = useStronglyConsistentReads;
// Don't await this - the table will either be ready or not on the first request
// In non-quick-and-dirty tests the table will be created before the application is
// ever started (via CDK, SAM, CloudFormation, Terraform, etc.)
if (this._createTableOptions !== undefined) {
void this.createTableIfNotExists();
}
}
/**
* Create the store and optionally await creation of the table.
*
* Note: Store-created tables is not advised for production use.
*
* @param options DynamoDBStore options
*/
static async create(options) {
const optionsMinusTableOptions = {
...options,
};
delete optionsMinusTableOptions.createTableOptions;
const store = new DynamoDBStore(optionsMinusTableOptions);
if (options.createTableOptions !== undefined) {
await store.createTableIfNotExists();
}
return store;
}
get(
/**
* Session ID
*/
sid,
/**
* Callback to return the session data
* @param err Error
* @param session Session data
* @returns void
*/
callback) {
void (async () => {
try {
const { Item } = await this._ddbDocClient.get({
TableName: this._tableName,
Key: {
[this._hashKey]: `${this._prefix}${sid}`,
},
ConsistentRead: this._useStronglyConsistentReads,
});
if (!Item) {
return callback(null, null);
}
// If session expired, return null
// Note: DynamoDB uses seconds since epoch for the TTL field value
if (Item.expires && Item.expires * 1000 < Date.now()) {
return callback(null, null);
}
// If no sessionData, return null
if (!Item.sess) {
return callback(null, null);
}
// If the sessionData is a string, try to parse it as JSON
if (typeof Item.sess === 'string') {
try {
Item.sess = JSON.parse(Item.sess);
}
catch (err) {
return callback(err);
}
}
// Return the session
callback(null, Item.sess);
}
catch (err) {
callback(err);
}
})();
}
set(
/**
* Session ID
*/
sid,
/**
* Session data
* @remarks
* The `expires` field is set by the session middleware and is used
* by DynamoDB to automatically expire the session.
* @see {@link https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html}
* @see {@link https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html}
* @see {@link https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/time-to-live-ttl-how-to.html}
*/
session,
/**
* Callback to return an error if the session was not saved
* @param err Error
* @returns void
*/
callback) {
void (async () => {
try {
await this._ddbDocClient.put({
TableName: this._tableName,
Item: {
[this._hashKey]: `${this._prefix}${sid}`,
// Note: DynamoDB uses seconds since epoch for the expires field
expires: this.newExpireSecondsSinceEpochUTC(session),
// The `cookie` object is not marshalled correctly by the DynamoDBDocument client
// so we strip the fields that we don't want and make sure the `expires` field
// is turned into a string
sess: {
...(0, deep_replace_dates_with_strings_1.deepReplaceDatesWithISOStrings)(session),
...(session.cookie
? { cookie: { ...JSON.parse(JSON.stringify(session.cookie)) } }
: {}),
// Add last-modified if touchAfter is set
...(this.touchAfter > 0 ? { lastModified: new Date().toISOString() } : {}),
},
},
});
if (callback) {
callback(null);
}
}
catch (error) {
if (callback) {
callback(error);
}
}
})();
}
/**
* Reset the TTL on the DynamoDB record to 100% of the original TTL
*
* @remarks
* This is called by the session middleware on every single `get` request.
*/
touch(
/**
* Session ID
*/
sid,
/**
* Session data
*/
session,
/**
* Callback to return an error if the session TTL was not updated
*/
callback) {
void (async () => {
try {
// The `expires` field from the DB `Item` is not available here
// when a session is loaded from the store
// We have to use a `lastModified` field within the user-visible session
const currentTimeSecs = Math.floor(Date.now() / 1000);
const lastModifiedSecs = session.lastModified
? Math.floor(new Date(session.lastModified).getTime() / 1000)
: 0;
// Update the TTL only if touchAfter
// seconds have passed since the TTL was last updated
const touchAfterSecsCapped = this._touchAfter * 1000 > session.cookie.originalMaxAge
? Math.floor(0.1 * (session.cookie.originalMaxAge / 1000))
: this._touchAfter;
const timeElapsed = currentTimeSecs - lastModifiedSecs;
if (timeElapsed < touchAfterSecsCapped) {
debug(`Skip touching session=${sid}`);
if (callback) {
callback(null);
}
return;
}
// We are going to touch the session, update the lastModified
session.lastModified = new Date().toISOString();
const newExpires = this.newExpireSecondsSinceEpochUTC(session);
await this._ddbDocClient.update({
TableName: this._tableName,
Key: {
[this._hashKey]: `${this._prefix}${sid}`,
},
UpdateExpression: 'set expires = :e, sess.lastModified = :lm',
ExpressionAttributeValues: {
':e': newExpires,
':lm': session.lastModified,
},
ReturnValues: 'UPDATED_NEW',
});
if (callback) {
callback(null);
}
}
catch (err) {
if (callback) {
callback(err);
}
}
})();
}
/**
* Destroy the session in DynamoDB
*/
destroy(
/**
* Session ID
*/
sid,
/**
* Callback to return an error if the session was not destroyed
* @param err Error
* @returns void
*/
callback) {
void (async () => {
try {
await this._ddbDocClient.delete({
TableName: this._tableName,
Key: {
[this._hashKey]: `${this._prefix}${sid}`,
},
});
if (callback) {
callback(null);
}
}
catch (err) {
if (callback) {
callback(err);
}
}
})();
}
newExpireSecondsSinceEpochUTC(sess) {
const expires = typeof sess.cookie.maxAge === 'number'
? +new Date() + sess.cookie.maxAge
: +new Date() + 60 * 60 * 24 * 1000;
return Math.floor(expires / 1000);
}
getTTLSeconds(sess) {
return sess && sess.cookie && sess.cookie.expires
? Math.ceil((Number(new Date(sess.cookie.expires)) - Date.now()) / 1000)
: this._touchAfter;
}
}
exports.DynamoDBStore = DynamoDBStore;
//# sourceMappingURL=dynamodb-store.js.map