UNPKG

@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
"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