serverless-simple-middleware 0.0.69 → 0.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ export declare class OncePromise<T> {
2
+ private promise?;
3
+ private factory?;
4
+ constructor(factory?: () => Promise<T>);
5
+ run(factory?: () => Promise<T>): Promise<T>;
6
+ reset(): void;
7
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OncePromise = void 0;
4
+ class OncePromise {
5
+ promise;
6
+ factory;
7
+ constructor(factory) {
8
+ this.factory = factory;
9
+ }
10
+ async run(factory) {
11
+ if (!this.promise) {
12
+ const f = factory || this.factory;
13
+ if (!f) {
14
+ throw new Error('OncePromise requires a factory');
15
+ }
16
+ this.promise = f();
17
+ try {
18
+ return await this.promise;
19
+ }
20
+ catch (err) {
21
+ this.promise = undefined;
22
+ throw err;
23
+ }
24
+ }
25
+ return this.promise;
26
+ }
27
+ reset() {
28
+ this.promise = undefined;
29
+ }
30
+ }
31
+ exports.OncePromise = OncePromise;
@@ -1,10 +1,14 @@
1
1
  import { MySQLPluginOptions } from '../mysql';
2
2
  export declare class ConnectionProxy {
3
- private pluginConfig;
3
+ private readonly options;
4
4
  private connection?;
5
+ private connectionConfig;
6
+ private secretsCache;
7
+ private configInitOnce;
8
+ private connectionInitOnce;
5
9
  private initialized;
6
10
  private dbName?;
7
- constructor(config: MySQLPluginOptions);
11
+ constructor(options: MySQLPluginOptions);
8
12
  query: <T>(sql: string, params?: any[]) => Promise<T | undefined>;
9
13
  fetch: <T>(sql: string, params?: any[]) => Promise<T[]>;
10
14
  fetchOne: <T>(sql: string, params?: any[], defaultValue?: T) => Promise<T>;
@@ -19,6 +23,7 @@ export declare class ConnectionProxy {
19
23
  destroyConnection: () => void;
20
24
  onPluginCreated: () => Promise<void>;
21
25
  private prepareConnection;
26
+ private ensureConnectionConfig;
22
27
  private changeDatabase;
23
28
  private tryToInitializeSchema;
24
29
  }
@@ -1,28 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConnectionProxy = void 0;
4
- const mysql_1 = require("mysql");
4
+ const mysql2_1 = require("mysql2");
5
+ const oncePromise_1 = require("../../internal/oncePromise");
5
6
  const utils_1 = require("../../utils");
7
+ const secretsManager_1 = require("../../utils/secretsManager");
6
8
  const logger = (0, utils_1.getLogger)(__filename);
7
9
  class ConnectionProxy {
8
- pluginConfig;
10
+ options;
9
11
  connection;
12
+ connectionConfig;
13
+ secretsCache;
14
+ configInitOnce = new oncePromise_1.OncePromise();
15
+ connectionInitOnce = new oncePromise_1.OncePromise();
10
16
  initialized;
11
17
  dbName;
12
- constructor(config) {
13
- this.pluginConfig = config;
14
- if (config.schema && config.schema.database) {
15
- this.dbName = config.config.database;
16
- config.config.database = undefined;
18
+ constructor(options) {
19
+ this.options = options;
20
+ if (options.schema && options.schema.database) {
21
+ this.dbName = options.config.database;
22
+ options.config.database = undefined;
17
23
  }
24
+ this.secretsCache = secretsManager_1.SecretsManagerCache.getInstance();
18
25
  }
19
26
  query = (sql, params) => new Promise(async (resolve, reject) => {
20
- const connection = this.prepareConnection();
27
+ const connection = await this.prepareConnection();
21
28
  await this.tryToInitializeSchema(false);
22
29
  if (process.env.NODE_ENV !== 'test') {
23
30
  logger.silly(`Execute query[${sql}] with params[${params}]`);
24
31
  }
25
- connection.query(sql, params, (err, result) => {
32
+ connection.query(sql, params, (err, result, _fields) => {
26
33
  if (err) {
27
34
  logger.error(`error occurred in database query=${sql}, error=${err}`);
28
35
  reject(err);
@@ -44,7 +51,7 @@ class ConnectionProxy {
44
51
  return res[0];
45
52
  });
46
53
  beginTransaction = () => new Promise(async (resolve, reject) => {
47
- const connection = this.prepareConnection();
54
+ const connection = await this.prepareConnection();
48
55
  await this.tryToInitializeSchema(false);
49
56
  connection.beginTransaction((err) => {
50
57
  if (err) {
@@ -55,7 +62,7 @@ class ConnectionProxy {
55
62
  });
56
63
  });
57
64
  commit = () => new Promise(async (resolve, reject) => {
58
- const connection = this.prepareConnection();
65
+ const connection = await this.prepareConnection();
59
66
  await this.tryToInitializeSchema(false);
60
67
  connection.commit((err) => {
61
68
  if (err) {
@@ -66,7 +73,7 @@ class ConnectionProxy {
66
73
  });
67
74
  });
68
75
  rollback = () => new Promise(async (resolve, reject) => {
69
- const connection = this.prepareConnection();
76
+ const connection = await this.prepareConnection();
70
77
  await this.tryToInitializeSchema(false);
71
78
  connection.rollback((err) => {
72
79
  if (err) {
@@ -80,6 +87,7 @@ class ConnectionProxy {
80
87
  if (this.connection) {
81
88
  this.connection.end();
82
89
  this.connection = undefined;
90
+ this.connectionInitOnce.reset();
83
91
  logger.verbose('Connection is end');
84
92
  }
85
93
  };
@@ -91,23 +99,48 @@ class ConnectionProxy {
91
99
  if (this.connection) {
92
100
  this.connection.destroy();
93
101
  this.connection = undefined;
102
+ this.connectionInitOnce.reset();
94
103
  logger.verbose('Connection is destroyed');
95
104
  }
96
105
  };
97
106
  onPluginCreated = async () => this.tryToInitializeSchema(true);
98
- prepareConnection = () => {
107
+ prepareConnection = async () => {
99
108
  if (this.connection) {
100
109
  return this.connection;
101
110
  }
102
- this.connection = (0, mysql_1.createConnection)(this.pluginConfig.config);
103
- this.connection.connect();
104
- return this.connection;
111
+ return await this.connectionInitOnce.run(async () => {
112
+ await this.ensureConnectionConfig();
113
+ const conn = (0, mysql2_1.createConnection)(this.connectionConfig);
114
+ conn.connect();
115
+ this.connection = conn;
116
+ return this.connection;
117
+ });
118
+ };
119
+ ensureConnectionConfig = async () => {
120
+ if (this.connectionConfig) {
121
+ return;
122
+ }
123
+ await this.configInitOnce.run(async () => {
124
+ const baseConfig = this.options.config;
125
+ if (!this.options.secretId) {
126
+ this.connectionConfig = baseConfig;
127
+ return;
128
+ }
129
+ const credentials = await this.secretsCache.getDatabaseCredentials(this.options.secretId);
130
+ this.connectionConfig = {
131
+ ...baseConfig,
132
+ user: credentials.username,
133
+ password: credentials.password,
134
+ };
135
+ });
105
136
  };
106
- changeDatabase = (dbName) => new Promise((resolve, reject) => this.prepareConnection().changeUser({
137
+ changeDatabase = async (dbName) => new Promise((resolve, reject) => this.prepareConnection()
138
+ .then((connection) => connection.changeUser({
107
139
  database: dbName,
108
- }, (err) => (err ? reject(err) : resolve(undefined))));
140
+ }, (err) => (err ? reject(err) : resolve(undefined))))
141
+ .catch(reject));
109
142
  tryToInitializeSchema = async (initial) => {
110
- const { eager = false, ignoreError = false, database = '', tables = {}, } = this.pluginConfig.schema || {};
143
+ const { eager = false, ignoreError = false, database = '', tables = {}, } = this.options.schema || {};
111
144
  if (initial && !eager) {
112
145
  return;
113
146
  }
@@ -1,8 +1,8 @@
1
1
  import { Kysely } from 'kysely';
2
- import { type ConnectionOptions } from 'mysql2';
2
+ import { MySQLPluginOptions } from '../mysql';
3
3
  export declare class SQLClient<T = unknown> extends Kysely<T> {
4
4
  private pool;
5
- constructor(config: ConnectionOptions);
5
+ constructor(config: MySQLPluginOptions);
6
6
  clearConnection: () => Promise<void>;
7
7
  /**
8
8
  * Destroy the connection socket immediately. No further events or callbacks will be triggered.
@@ -3,31 +3,66 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sql = exports.expressionBuilder = exports.SQLClient = void 0;
4
4
  const kysely_1 = require("kysely");
5
5
  const mysql2_1 = require("mysql2");
6
+ const oncePromise_1 = require("../../internal/oncePromise");
7
+ const secretsManager_1 = require("../../utils/secretsManager");
6
8
  class LazyConnectionPool {
7
- config;
9
+ options;
8
10
  connection = null;
9
- constructor(config) {
10
- this.config = config;
11
+ connectionConfig;
12
+ secretsCache;
13
+ configInitOnce = new oncePromise_1.OncePromise();
14
+ connectionInitOnce = new oncePromise_1.OncePromise();
15
+ constructor(options) {
16
+ this.options = options;
17
+ this.secretsCache = secretsManager_1.SecretsManagerCache.getInstance();
11
18
  }
12
- getConnection = (callback) => {
13
- if (this.connection) {
14
- callback(null, this.connection);
19
+ ensureConnectionConfig = async () => {
20
+ if (this.connectionConfig) {
15
21
  return;
16
22
  }
17
- const conn = (0, mysql2_1.createConnection)(this.config);
18
- conn.connect((err) => {
19
- if (err) {
20
- callback(err, {});
23
+ await this.configInitOnce.run(async () => {
24
+ const baseConfig = this.options.config;
25
+ if (!this.options.secretId) {
26
+ this.connectionConfig = baseConfig;
21
27
  return;
22
28
  }
23
- this.connection = this._addRelease(conn);
24
- callback(null, this.connection);
29
+ const credentials = await this.secretsCache.getDatabaseCredentials(this.options.secretId);
30
+ this.connectionConfig = {
31
+ ...baseConfig,
32
+ user: credentials.username,
33
+ password: credentials.password,
34
+ };
25
35
  });
26
36
  };
37
+ getConnection = (callback) => {
38
+ if (this.connection) {
39
+ callback(null, this.connection);
40
+ return;
41
+ }
42
+ this.connectionInitOnce
43
+ .run(async () => {
44
+ await this.ensureConnectionConfig();
45
+ const conn = (0, mysql2_1.createConnection)(this.connectionConfig);
46
+ return await new Promise((resolve, reject) => {
47
+ conn.connect((err) => {
48
+ if (err) {
49
+ reject(err);
50
+ return;
51
+ }
52
+ const wrapped = this._addRelease(conn);
53
+ this.connection = wrapped;
54
+ resolve(wrapped);
55
+ });
56
+ });
57
+ })
58
+ .then((conn) => callback(null, conn))
59
+ .catch((err) => callback(err, {}));
60
+ };
27
61
  end = (callback) => {
28
62
  if (this.connection) {
29
63
  this.connection.end((err) => {
30
64
  this.connection = null;
65
+ this.connectionInitOnce.reset();
31
66
  callback(err);
32
67
  });
33
68
  }
@@ -39,6 +74,7 @@ class LazyConnectionPool {
39
74
  if (this.connection) {
40
75
  this.connection.destroy();
41
76
  this.connection = null;
77
+ this.connectionInitOnce.reset();
42
78
  }
43
79
  };
44
80
  _addRelease = (connection) => Object.assign(connection, {
@@ -49,7 +85,7 @@ class SQLClient extends kysely_1.Kysely {
49
85
  pool;
50
86
  constructor(config) {
51
87
  const pool = new LazyConnectionPool(config);
52
- super({
88
+ const kyselyConfig = {
53
89
  dialect: new kysely_1.MysqlDialect({
54
90
  pool,
55
91
  }),
@@ -58,7 +94,8 @@ class SQLClient extends kysely_1.Kysely {
58
94
  strategy: kysely_1.replaceWithNoncontingentExpression,
59
95
  }),
60
96
  ],
61
- });
97
+ };
98
+ super(kyselyConfig);
62
99
  this.pool = pool;
63
100
  }
64
101
  clearConnection = () => new Promise((resolve) => {
@@ -1,10 +1,17 @@
1
- import type { ConnectionConfig } from 'mysql';
2
- import type { PoolOptions } from 'mysql2';
1
+ import type { ConnectionOptions, PoolOptions } from 'mysql2';
3
2
  import { HandlerAuxBase, HandlerPluginBase } from './base';
4
3
  import { ConnectionProxy } from './database/connectionProxy';
5
4
  import { SQLClient } from './database/sqlClient';
5
+ export interface DatabaseCredentials {
6
+ username: string;
7
+ password: string;
8
+ }
6
9
  export interface MySQLPluginOptions {
7
- config: ConnectionConfig & PoolOptions;
10
+ config: ConnectionOptions & PoolOptions;
11
+ /**
12
+ * AWS Secrets Manager secret ID containing {@link DatabaseCredentials}
13
+ */
14
+ secretId?: string;
8
15
  schema?: {
9
16
  eager?: boolean;
10
17
  ignoreError?: boolean;
@@ -10,7 +10,7 @@ class MySQLPlugin extends base_1.HandlerPluginBase {
10
10
  constructor(options) {
11
11
  super();
12
12
  this.proxy = new connectionProxy_1.ConnectionProxy(options);
13
- this.sqlClient = new sqlClient_1.SQLClient(options.config);
13
+ this.sqlClient = new sqlClient_1.SQLClient(options);
14
14
  }
15
15
  create = async () => {
16
16
  await this.proxy.onPluginCreated();
@@ -0,0 +1,11 @@
1
+ import type { DatabaseCredentials } from '../middleware/mysql';
2
+ export declare class SecretsManagerCache {
3
+ private static instance;
4
+ private client;
5
+ private cache;
6
+ private constructor();
7
+ static getInstance(): SecretsManagerCache;
8
+ private getClient;
9
+ getSecret<T = any>(secretId: string): Promise<T>;
10
+ getDatabaseCredentials(secretId: string): Promise<DatabaseCredentials>;
11
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SecretsManagerCache = void 0;
4
+ const client_secrets_manager_1 = require("@aws-sdk/client-secrets-manager");
5
+ const logger_1 = require("./logger");
6
+ const misc_1 = require("./misc");
7
+ const logger = (0, logger_1.getLogger)(__filename);
8
+ class SecretsManagerCache {
9
+ static instance;
10
+ client;
11
+ cache = new Map();
12
+ constructor() { }
13
+ static getInstance() {
14
+ if (!SecretsManagerCache.instance) {
15
+ SecretsManagerCache.instance = new SecretsManagerCache();
16
+ }
17
+ return SecretsManagerCache.instance;
18
+ }
19
+ getClient() {
20
+ if (!this.client) {
21
+ this.client = new client_secrets_manager_1.SecretsManagerClient({});
22
+ logger.debug('SecretsManager client initialized');
23
+ }
24
+ return this.client;
25
+ }
26
+ async getSecret(secretId) {
27
+ if (this.cache.has(secretId)) {
28
+ logger.debug(`Secret ${secretId} found in cache`);
29
+ return this.cache.get(secretId);
30
+ }
31
+ try {
32
+ const command = new client_secrets_manager_1.GetSecretValueCommand({ SecretId: secretId });
33
+ const response = await this.getClient().send(command);
34
+ if (!response.SecretString) {
35
+ throw new Error(`Secret ${secretId} has no SecretString value`);
36
+ }
37
+ const secretValue = JSON.parse(response.SecretString);
38
+ this.cache.set(secretId, secretValue);
39
+ return secretValue;
40
+ }
41
+ catch (error) {
42
+ logger.error(`Failed to fetch secret ${secretId}: ${(0, misc_1.stringifyError)(error)}`);
43
+ throw error;
44
+ }
45
+ }
46
+ async getDatabaseCredentials(secretId) {
47
+ const secret = await this.getSecret(secretId);
48
+ if (!secret.username || !secret.password) {
49
+ throw new Error(`Secret ${secretId} does not contain required database credentials (username, password)`);
50
+ }
51
+ return secret;
52
+ }
53
+ }
54
+ exports.SecretsManagerCache = SecretsManagerCache;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "serverless-simple-middleware",
3
3
  "description": "Simple middleware to translate the interface of lambda's handler to request => response",
4
- "version": "0.0.69",
4
+ "version": "0.0.71",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "author": "VoyagerX",
@@ -32,19 +32,17 @@
32
32
  "dependencies": {
33
33
  "@aws-sdk/client-dynamodb": "^3.828.0",
34
34
  "@aws-sdk/client-s3": "^3.828.0",
35
+ "@aws-sdk/client-secrets-manager": "3.828.0",
35
36
  "@aws-sdk/client-sqs": "^3.828.0",
36
37
  "@aws-sdk/cloudfront-signer": "^3.821.0",
37
38
  "@aws-sdk/lib-dynamodb": "^3.828.0",
38
39
  "@aws-sdk/lib-storage": "^3.828.0",
39
40
  "@aws-sdk/s3-request-presigner": "^3.828.0",
40
41
  "@types/aws-lambda": "8",
41
- "@types/mysql": "^2.15.5",
42
42
  "cross-fetch": "^2.2.2",
43
43
  "kysely": "^0.28.2",
44
- "mysql": "^2.16.0",
45
44
  "mysql2": "^3.14.1",
46
45
  "nanoid": "4.0.2",
47
- "p-limit": "^2.0.0",
48
46
  "simple-staging": "^0.0.12",
49
47
  "ts-enum-util": "^3.1.0",
50
48
  "uuid": "^3.3.2"
@@ -0,0 +1,29 @@
1
+ export class OncePromise<T> {
2
+ private promise?: Promise<T>;
3
+ private factory?: () => Promise<T>;
4
+
5
+ constructor(factory?: () => Promise<T>) {
6
+ this.factory = factory;
7
+ }
8
+
9
+ public async run(factory?: () => Promise<T>): Promise<T> {
10
+ if (!this.promise) {
11
+ const f = factory || this.factory;
12
+ if (!f) {
13
+ throw new Error('OncePromise requires a factory');
14
+ }
15
+ this.promise = f();
16
+ try {
17
+ return await this.promise;
18
+ } catch (err) {
19
+ this.promise = undefined;
20
+ throw err;
21
+ }
22
+ }
23
+ return this.promise;
24
+ }
25
+
26
+ public reset(): void {
27
+ this.promise = undefined;
28
+ }
29
+ }
@@ -1,43 +1,61 @@
1
- import { createConnection, type Connection, type MysqlError } from 'mysql';
1
+ import {
2
+ createConnection,
3
+ type Connection,
4
+ type ConnectionOptions,
5
+ type FieldPacket,
6
+ type QueryError,
7
+ type QueryResult,
8
+ } from 'mysql2';
9
+ import { OncePromise } from '../../internal/oncePromise';
2
10
  import { getLogger } from '../../utils';
11
+ import { SecretsManagerCache } from '../../utils/secretsManager';
3
12
  import { MySQLPluginOptions } from '../mysql';
4
13
 
5
14
  const logger = getLogger(__filename);
6
15
 
7
16
  export class ConnectionProxy {
8
- private pluginConfig: MySQLPluginOptions;
9
17
  private connection?: Connection;
18
+ private connectionConfig: ConnectionOptions;
19
+ private secretsCache: SecretsManagerCache;
20
+ private configInitOnce = new OncePromise<void>();
21
+ private connectionInitOnce = new OncePromise<Connection>();
10
22
 
11
23
  private initialized: boolean;
12
24
  private dbName?: string;
13
25
 
14
- public constructor(config: MySQLPluginOptions) {
15
- this.pluginConfig = config;
16
- if (config.schema && config.schema.database) {
17
- this.dbName = config.config.database;
18
- config.config.database = undefined;
26
+ public constructor(private readonly options: MySQLPluginOptions) {
27
+ if (options.schema && options.schema.database) {
28
+ this.dbName = options.config.database;
29
+ options.config.database = undefined;
19
30
  }
31
+ this.secretsCache = SecretsManagerCache.getInstance();
20
32
  }
21
33
 
22
34
  public query = <T>(sql: string, params?: any[]) =>
23
35
  new Promise<T | undefined>(async (resolve, reject) => {
24
- const connection = this.prepareConnection();
36
+ const connection = await this.prepareConnection();
25
37
  await this.tryToInitializeSchema(false);
26
38
 
27
39
  if (process.env.NODE_ENV !== 'test') {
28
40
  logger.silly(`Execute query[${sql}] with params[${params}]`);
29
41
  }
30
- connection.query(sql, params, (err: MysqlError, result?: T) => {
31
- if (err) {
32
- logger.error(`error occurred in database query=${sql}, error=${err}`);
33
- reject(err);
34
- } else {
35
- resolve(result);
36
- if (process.env.NODE_ENV !== 'test') {
37
- logger.silly(`DB result is ${JSON.stringify(result)}`);
42
+ connection.query(
43
+ sql,
44
+ params,
45
+ (err: QueryError, result: QueryResult, _fields?: FieldPacket[]) => {
46
+ if (err) {
47
+ logger.error(
48
+ `error occurred in database query=${sql}, error=${err}`,
49
+ );
50
+ reject(err);
51
+ } else {
52
+ resolve(result as T);
53
+ if (process.env.NODE_ENV !== 'test') {
54
+ logger.silly(`DB result is ${JSON.stringify(result)}`);
55
+ }
38
56
  }
39
- }
40
- });
57
+ },
58
+ );
41
59
  });
42
60
 
43
61
  public fetch = <T>(sql: string, params?: any[]) =>
@@ -54,10 +72,10 @@ export class ConnectionProxy {
54
72
 
55
73
  public beginTransaction = () =>
56
74
  new Promise<void>(async (resolve, reject) => {
57
- const connection = this.prepareConnection();
75
+ const connection = await this.prepareConnection();
58
76
  await this.tryToInitializeSchema(false);
59
77
 
60
- connection.beginTransaction((err: MysqlError) => {
78
+ connection.beginTransaction((err: QueryError) => {
61
79
  if (err) {
62
80
  reject(err);
63
81
  return;
@@ -68,10 +86,10 @@ export class ConnectionProxy {
68
86
 
69
87
  public commit = () =>
70
88
  new Promise<void>(async (resolve, reject) => {
71
- const connection = this.prepareConnection();
89
+ const connection = await this.prepareConnection();
72
90
  await this.tryToInitializeSchema(false);
73
91
 
74
- connection.commit((err: MysqlError) => {
92
+ connection.commit((err: QueryError) => {
75
93
  if (err) {
76
94
  reject(err);
77
95
  return;
@@ -82,10 +100,10 @@ export class ConnectionProxy {
82
100
 
83
101
  public rollback = () =>
84
102
  new Promise<void>(async (resolve, reject) => {
85
- const connection = this.prepareConnection();
103
+ const connection = await this.prepareConnection();
86
104
  await this.tryToInitializeSchema(false);
87
105
 
88
- connection.rollback((err: MysqlError) => {
106
+ connection.rollback((err: QueryError) => {
89
107
  if (err) {
90
108
  reject(err);
91
109
  return;
@@ -98,6 +116,7 @@ export class ConnectionProxy {
98
116
  if (this.connection) {
99
117
  this.connection.end();
100
118
  this.connection = undefined;
119
+ this.connectionInitOnce.reset();
101
120
  logger.verbose('Connection is end');
102
121
  }
103
122
  };
@@ -110,29 +129,61 @@ export class ConnectionProxy {
110
129
  if (this.connection) {
111
130
  this.connection.destroy();
112
131
  this.connection = undefined;
132
+ this.connectionInitOnce.reset();
113
133
  logger.verbose('Connection is destroyed');
114
134
  }
115
135
  };
116
136
 
117
137
  public onPluginCreated = async () => this.tryToInitializeSchema(true);
118
138
 
119
- private prepareConnection = () => {
139
+ private prepareConnection = async (): Promise<Connection> => {
120
140
  if (this.connection) {
121
141
  return this.connection;
122
142
  }
123
- this.connection = createConnection(this.pluginConfig.config);
124
- this.connection.connect();
125
- return this.connection;
143
+
144
+ return await this.connectionInitOnce.run(async () => {
145
+ await this.ensureConnectionConfig();
146
+ const conn = createConnection(this.connectionConfig);
147
+ conn.connect();
148
+ this.connection = conn;
149
+ return this.connection;
150
+ });
151
+ };
152
+
153
+ private ensureConnectionConfig = async (): Promise<void> => {
154
+ if (this.connectionConfig) {
155
+ return;
156
+ }
157
+
158
+ await this.configInitOnce.run(async () => {
159
+ const baseConfig = this.options.config;
160
+ if (!this.options.secretId) {
161
+ this.connectionConfig = baseConfig;
162
+ return;
163
+ }
164
+ const credentials = await this.secretsCache.getDatabaseCredentials(
165
+ this.options.secretId,
166
+ );
167
+ this.connectionConfig = {
168
+ ...baseConfig,
169
+ user: credentials.username,
170
+ password: credentials.password,
171
+ };
172
+ });
126
173
  };
127
174
 
128
- private changeDatabase = (dbName: string) =>
175
+ private changeDatabase = async (dbName: string) =>
129
176
  new Promise<void>((resolve, reject) =>
130
- this.prepareConnection().changeUser(
131
- {
132
- database: dbName,
133
- },
134
- (err) => (err ? reject(err) : resolve(undefined)),
135
- ),
177
+ this.prepareConnection()
178
+ .then((connection) =>
179
+ connection.changeUser(
180
+ {
181
+ database: dbName,
182
+ },
183
+ (err) => (err ? reject(err) : resolve(undefined)),
184
+ ),
185
+ )
186
+ .catch(reject),
136
187
  );
137
188
 
138
189
  private tryToInitializeSchema = async (initial: boolean) => {
@@ -141,7 +192,7 @@ export class ConnectionProxy {
141
192
  ignoreError = false,
142
193
  database = '',
143
194
  tables = {},
144
- } = this.pluginConfig.schema || {};
195
+ } = this.options.schema || {};
145
196
  if (initial && !eager) {
146
197
  return;
147
198
  }
@@ -1,16 +1,20 @@
1
1
  import {
2
2
  HandleEmptyInListsPlugin,
3
3
  Kysely,
4
+ type KyselyConfig,
4
5
  MysqlDialect,
5
- MysqlPool,
6
+ type MysqlPool,
6
7
  replaceWithNoncontingentExpression,
7
8
  } from 'kysely';
8
9
  import {
9
- createConnection,
10
10
  type Connection,
11
11
  type ConnectionOptions,
12
+ createConnection,
12
13
  type QueryError,
13
14
  } from 'mysql2';
15
+ import { OncePromise } from '../../internal/oncePromise';
16
+ import { SecretsManagerCache } from '../../utils/secretsManager';
17
+ import { MySQLPluginOptions } from '../mysql';
14
18
 
15
19
  interface LazyMysqlPoolConnection extends Connection {
16
20
  release: () => void;
@@ -18,8 +22,37 @@ interface LazyMysqlPoolConnection extends Connection {
18
22
 
19
23
  class LazyConnectionPool implements MysqlPool {
20
24
  private connection: LazyMysqlPoolConnection | null = null;
25
+ private connectionConfig: ConnectionOptions;
26
+ private secretsCache: SecretsManagerCache;
27
+ private configInitOnce = new OncePromise<void>();
28
+ private connectionInitOnce = new OncePromise<LazyMysqlPoolConnection>();
29
+
30
+ constructor(private readonly options: MySQLPluginOptions) {
31
+ this.secretsCache = SecretsManagerCache.getInstance();
32
+ }
33
+
34
+ private ensureConnectionConfig = async (): Promise<void> => {
35
+ if (this.connectionConfig) {
36
+ return;
37
+ }
38
+
39
+ await this.configInitOnce.run(async () => {
40
+ const baseConfig = this.options.config;
41
+ if (!this.options.secretId) {
42
+ this.connectionConfig = baseConfig;
43
+ return;
44
+ }
45
+ const credentials = await this.secretsCache.getDatabaseCredentials(
46
+ this.options.secretId,
47
+ );
21
48
 
22
- constructor(private config: ConnectionOptions) {}
49
+ this.connectionConfig = {
50
+ ...baseConfig,
51
+ user: credentials.username,
52
+ password: credentials.password,
53
+ };
54
+ });
55
+ };
23
56
 
24
57
  public getConnection = (
25
58
  callback: (error: unknown, connection: LazyMysqlPoolConnection) => void,
@@ -28,21 +61,32 @@ class LazyConnectionPool implements MysqlPool {
28
61
  callback(null, this.connection);
29
62
  return;
30
63
  }
31
- const conn = createConnection(this.config);
32
- conn.connect((err: QueryError) => {
33
- if (err) {
34
- callback(err, {} as LazyMysqlPoolConnection);
35
- return;
36
- }
37
- this.connection = this._addRelease(conn);
38
- callback(null, this.connection);
39
- });
64
+
65
+ this.connectionInitOnce
66
+ .run(async () => {
67
+ await this.ensureConnectionConfig();
68
+ const conn = createConnection(this.connectionConfig);
69
+ return await new Promise<LazyMysqlPoolConnection>((resolve, reject) => {
70
+ conn.connect((err: QueryError) => {
71
+ if (err) {
72
+ reject(err);
73
+ return;
74
+ }
75
+ const wrapped = this._addRelease(conn);
76
+ this.connection = wrapped;
77
+ resolve(wrapped);
78
+ });
79
+ });
80
+ })
81
+ .then((conn) => callback(null, conn))
82
+ .catch((err) => callback(err, {} as LazyMysqlPoolConnection));
40
83
  };
41
84
 
42
85
  public end = (callback: (error: unknown) => void): void => {
43
86
  if (this.connection) {
44
87
  this.connection.end((err: QueryError) => {
45
88
  this.connection = null;
89
+ this.connectionInitOnce.reset();
46
90
  callback(err);
47
91
  });
48
92
  } else {
@@ -54,6 +98,7 @@ class LazyConnectionPool implements MysqlPool {
54
98
  if (this.connection) {
55
99
  this.connection.destroy();
56
100
  this.connection = null;
101
+ this.connectionInitOnce.reset();
57
102
  }
58
103
  };
59
104
 
@@ -66,9 +111,9 @@ class LazyConnectionPool implements MysqlPool {
66
111
  export class SQLClient<T = unknown> extends Kysely<T> {
67
112
  private pool: LazyConnectionPool;
68
113
 
69
- constructor(config: ConnectionOptions) {
114
+ constructor(config: MySQLPluginOptions) {
70
115
  const pool = new LazyConnectionPool(config);
71
- super({
116
+ const kyselyConfig: KyselyConfig = {
72
117
  dialect: new MysqlDialect({
73
118
  pool,
74
119
  }),
@@ -77,7 +122,8 @@ export class SQLClient<T = unknown> extends Kysely<T> {
77
122
  strategy: replaceWithNoncontingentExpression,
78
123
  }),
79
124
  ],
80
- });
125
+ };
126
+ super(kyselyConfig);
81
127
  this.pool = pool;
82
128
  }
83
129
 
@@ -1,11 +1,19 @@
1
- import type { ConnectionConfig } from 'mysql';
2
- import type { PoolOptions } from 'mysql2';
1
+ import type { ConnectionOptions, PoolOptions } from 'mysql2';
3
2
  import { HandlerAuxBase, HandlerPluginBase } from './base';
4
3
  import { ConnectionProxy } from './database/connectionProxy';
5
4
  import { SQLClient } from './database/sqlClient';
6
5
 
6
+ export interface DatabaseCredentials {
7
+ username: string;
8
+ password: string;
9
+ }
10
+
7
11
  export interface MySQLPluginOptions {
8
- config: ConnectionConfig & PoolOptions;
12
+ config: ConnectionOptions & PoolOptions;
13
+ /**
14
+ * AWS Secrets Manager secret ID containing {@link DatabaseCredentials}
15
+ */
16
+ secretId?: string;
9
17
  schema?: {
10
18
  eager?: boolean;
11
19
  ignoreError?: boolean;
@@ -30,7 +38,7 @@ export class MySQLPlugin<T = unknown> extends HandlerPluginBase<
30
38
  constructor(options: MySQLPluginOptions) {
31
39
  super();
32
40
  this.proxy = new ConnectionProxy(options);
33
- this.sqlClient = new SQLClient(options.config);
41
+ this.sqlClient = new SQLClient(options);
34
42
  }
35
43
 
36
44
  public create = async () => {
@@ -0,0 +1,73 @@
1
+ import {
2
+ GetSecretValueCommand,
3
+ SecretsManagerClient,
4
+ } from '@aws-sdk/client-secrets-manager';
5
+ import type { DatabaseCredentials } from '../middleware/mysql';
6
+ import { getLogger } from './logger';
7
+ import { stringifyError } from './misc';
8
+
9
+ const logger = getLogger(__filename);
10
+
11
+ export class SecretsManagerCache {
12
+ private static instance: SecretsManagerCache;
13
+ private client: SecretsManagerClient | undefined;
14
+ private cache = new Map<string, any>();
15
+
16
+ private constructor() {}
17
+
18
+ public static getInstance(): SecretsManagerCache {
19
+ if (!SecretsManagerCache.instance) {
20
+ SecretsManagerCache.instance = new SecretsManagerCache();
21
+ }
22
+ return SecretsManagerCache.instance;
23
+ }
24
+
25
+ private getClient(): SecretsManagerClient {
26
+ if (!this.client) {
27
+ this.client = new SecretsManagerClient({});
28
+ logger.debug('SecretsManager client initialized');
29
+ }
30
+ return this.client;
31
+ }
32
+
33
+ public async getSecret<T = any>(secretId: string): Promise<T> {
34
+ if (this.cache.has(secretId)) {
35
+ logger.debug(`Secret ${secretId} found in cache`);
36
+ return this.cache.get(secretId);
37
+ }
38
+
39
+ try {
40
+ const command = new GetSecretValueCommand({ SecretId: secretId });
41
+ const response = await this.getClient().send(command);
42
+
43
+ if (!response.SecretString) {
44
+ throw new Error(`Secret ${secretId} has no SecretString value`);
45
+ }
46
+
47
+ const secretValue = JSON.parse(response.SecretString);
48
+
49
+ this.cache.set(secretId, secretValue);
50
+
51
+ return secretValue;
52
+ } catch (error) {
53
+ logger.error(
54
+ `Failed to fetch secret ${secretId}: ${stringifyError(error)}`,
55
+ );
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ public async getDatabaseCredentials(
61
+ secretId: string,
62
+ ): Promise<DatabaseCredentials> {
63
+ const secret = await this.getSecret<DatabaseCredentials>(secretId);
64
+
65
+ if (!secret.username || !secret.password) {
66
+ throw new Error(
67
+ `Secret ${secretId} does not contain required database credentials (username, password)`,
68
+ );
69
+ }
70
+
71
+ return secret;
72
+ }
73
+ }