@devmoods/express-extras 0.22.3 → 0.24.0
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.
- package/README.md +18 -12
- package/dist/auth.d.ts +4 -4
- package/dist/auth.js +25 -25
- package/dist/faktory.d.ts +1 -1
- package/dist/faktory.js +4 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/listen.js +4 -3
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +5 -10
- package/dist/migrate.d.ts +4 -3
- package/dist/migrate.js +44 -13
- package/dist/postgres.d.ts +2 -1
- package/dist/postgres.js +3 -2
- package/dist/redis.js +2 -1
- package/dist/sentry.d.ts +1 -0
- package/dist/sentry.js +6 -2
- package/dist/setupTests.d.ts +1 -0
- package/dist/setupTests.js +9 -0
- package/dist/slack.js +2 -1
- package/dist/utils.d.ts +17 -0
- package/dist/utils.js +27 -0
- package/dist/validate.d.ts +1 -1
- package/dist/validate.js +4 -3
- package/package.json +15 -12
package/README.md
CHANGED
|
@@ -8,15 +8,17 @@ _Extremely opinionated_ set of utilities for cranking out production-ready Node.
|
|
|
8
8
|
- Thin layer on top of [Express](https://expressjs.com) with better support for `async`/`await`
|
|
9
9
|
- PostgreSQL with transactions and migrations
|
|
10
10
|
- Redis
|
|
11
|
-
- Slack notifications
|
|
11
|
+
- Slack (and Discord) client for sending notifications
|
|
12
12
|
- Delayed jobs with [Faktory](https://contribsys.com/faktory/)
|
|
13
|
-
- Validation of requests using [JSON Schemas](https://json-schema.org/)
|
|
14
|
-
- Logging helpers
|
|
15
|
-
- Graceful shutdown
|
|
16
|
-
- Read configuration from `.env` files
|
|
13
|
+
- Validation of HTTP requests using [JSON Schemas](https://json-schema.org/)
|
|
14
|
+
- Logging helpers to write well-formatted contextual JSON logs to `stdout`
|
|
17
15
|
- Request local context using [AsyncLocalStorage](https://nodejs.org/api/async_context.html)
|
|
18
16
|
- [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) by default
|
|
19
17
|
- Works best with the [PnPM](https://pnpm.io/) package manager
|
|
18
|
+
- Uses [Sentry](https://sentry.io/) for external error monitoring
|
|
19
|
+
- Inspired by [The Twelve-Factor App](https://12factor.net/)
|
|
20
|
+
- Graceful shutdown
|
|
21
|
+
- Read configuration from `.env` files
|
|
20
22
|
|
|
21
23
|
## Goals
|
|
22
24
|
|
|
@@ -35,13 +37,17 @@ _Extremely opinionated_ set of utilities for cranking out production-ready Node.
|
|
|
35
37
|
Put environment variables in `.env` at the application will automatically read those on startup. For test configuration in unit and integrations tests, use `.env.test`.
|
|
36
38
|
|
|
37
39
|
```bash
|
|
38
|
-
|
|
40
|
+
NODE_ENV=development
|
|
41
|
+
PORT=3000
|
|
42
|
+
READINESS_PROBE_MS=
|
|
39
43
|
FORCE_EXIT_TIMEOUT=
|
|
40
|
-
|
|
44
|
+
|
|
45
|
+
APP_JWT_SECRET=123456
|
|
46
|
+
|
|
47
|
+
POSTGRES_URL=postgresql://user:pass@localhost:5432/my_database
|
|
41
48
|
REDIS_URL=
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
PGPORT=5432
|
|
49
|
+
|
|
50
|
+
SLACK_WEBHOOK_URL=
|
|
51
|
+
SENTRY_DSN=
|
|
52
|
+
SLACK_WEBHOOK_URL=
|
|
47
53
|
```
|
package/dist/auth.d.ts
CHANGED
|
@@ -10,18 +10,18 @@ export interface AuthOptions<UserModel extends Record<string, any>> {
|
|
|
10
10
|
redis: RedisClient;
|
|
11
11
|
filterUser(user: UserModel): Partial<UserModel>;
|
|
12
12
|
tokenExpiry?: number;
|
|
13
|
-
|
|
13
|
+
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
14
14
|
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
15
|
+
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
15
16
|
onEmailVerified?(user: UserModel): Promise<void>;
|
|
16
|
-
onEmailVerificationRequest?(email: string): Promise<void>;
|
|
17
17
|
}
|
|
18
18
|
export interface CreateAuth<UserModel extends Record<string, any>> {
|
|
19
19
|
createRouter: () => Router;
|
|
20
20
|
useCurrentUser: () => UserModel | undefined;
|
|
21
21
|
middleware: (authorize?: boolean) => RequestHandler;
|
|
22
|
-
|
|
22
|
+
createTemporaryToken(user: UserModel): Promise<string>;
|
|
23
23
|
}
|
|
24
|
-
export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry,
|
|
24
|
+
export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry, onPasswordResetRequest, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
|
|
25
25
|
/**
|
|
26
26
|
* Extract a Bearer token from the HTTP Authorization header
|
|
27
27
|
* of `req`.
|
package/dist/auth.js
CHANGED
|
@@ -8,13 +8,15 @@ import { BadRequest, NotFound, Unauthorized } from './errors.js';
|
|
|
8
8
|
import { getLogger } from './logger.js';
|
|
9
9
|
import { route } from './middleware.js';
|
|
10
10
|
import { validateBody, validateParams, } from './validate.js';
|
|
11
|
+
import { readEnv } from './utils.js';
|
|
11
12
|
const logger = getLogger('auth');
|
|
12
|
-
const TOKEN_EXPIRES_IN = 3600 * 48;
|
|
13
|
-
const TEMP_TOKEN_EXIRES_IN = 3600;
|
|
14
|
-
|
|
13
|
+
const TOKEN_EXPIRES_IN = readEnv.number('AUTH_TOKEN_EXPIRES', 3600 * 48);
|
|
14
|
+
const TEMP_TOKEN_EXIRES_IN = readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600);
|
|
15
|
+
const AUTH_SECRET = readEnv('AUTH_JWT_SECRET', '');
|
|
16
|
+
export function createAuthToken(payload, secret = AUTH_SECRET) {
|
|
15
17
|
return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
|
|
16
18
|
}
|
|
17
|
-
export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN,
|
|
19
|
+
export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN, onPasswordResetRequest, onPasswordChanged, onEmailVerified, onEmailVerificationRequest, }) {
|
|
18
20
|
const useCurrentUser = () => useContext('currentUser');
|
|
19
21
|
const setCurrentUser = (user) => setContext('currentUser', user);
|
|
20
22
|
const skipPasswordCheck = async () => true;
|
|
@@ -88,7 +90,12 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
88
90
|
required: ['email'],
|
|
89
91
|
}), route(async (req) => {
|
|
90
92
|
const { email } = req.body;
|
|
91
|
-
|
|
93
|
+
if (!emailAndPassword) {
|
|
94
|
+
throw new Error('Not applicable for login method');
|
|
95
|
+
}
|
|
96
|
+
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
97
|
+
const temporaryToken = await createTemporaryToken(user);
|
|
98
|
+
await onEmailVerificationRequest?.(user, temporaryToken);
|
|
92
99
|
return 202;
|
|
93
100
|
}));
|
|
94
101
|
router.post('/verify-email', validateBody({
|
|
@@ -96,20 +103,20 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
96
103
|
properties: { token: tokenSchema, email: emailSchema },
|
|
97
104
|
required: ['token', 'email'],
|
|
98
105
|
}), route(async (req) => {
|
|
99
|
-
const { token } = req.body;
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
106
|
+
const { token, email } = req.body;
|
|
107
|
+
const storedEmail = await redis.get(token);
|
|
108
|
+
if (!storedEmail) {
|
|
102
109
|
logger.info('invalid verification token', { token });
|
|
103
110
|
throw BadRequest('invalid verification token');
|
|
104
111
|
}
|
|
105
|
-
if (
|
|
112
|
+
if (storedEmail !== email) {
|
|
106
113
|
logger.info('email verification mismatch', {
|
|
107
|
-
expected:
|
|
108
|
-
actual:
|
|
114
|
+
expected: storedEmail,
|
|
115
|
+
actual: email,
|
|
109
116
|
});
|
|
110
117
|
throw BadRequest('invalid verification token');
|
|
111
118
|
}
|
|
112
|
-
const user = await emailAndPassword(
|
|
119
|
+
const user = await emailAndPassword(storedEmail, skipPasswordCheck);
|
|
113
120
|
setCurrentUser(user);
|
|
114
121
|
await onEmailVerified?.(user);
|
|
115
122
|
await redis.del(token);
|
|
@@ -141,17 +148,14 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
141
148
|
properties: { email: emailSchema },
|
|
142
149
|
required: ['email'],
|
|
143
150
|
}), route(async (req) => {
|
|
144
|
-
const email = req.body
|
|
151
|
+
const { email } = req.body;
|
|
145
152
|
if (!emailAndPassword) {
|
|
146
153
|
throw new Error('Not applicable for login method');
|
|
147
154
|
}
|
|
148
155
|
try {
|
|
149
156
|
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
150
|
-
const temporaryToken =
|
|
151
|
-
await
|
|
152
|
-
EX: TEMP_TOKEN_EXIRES_IN,
|
|
153
|
-
});
|
|
154
|
-
await onPasswordResetRequested?.(user, temporaryToken);
|
|
157
|
+
const temporaryToken = await createTemporaryToken(user);
|
|
158
|
+
await onPasswordResetRequest?.(user, temporaryToken);
|
|
155
159
|
logger.info('password reset request', { email });
|
|
156
160
|
}
|
|
157
161
|
catch (error) {
|
|
@@ -191,7 +195,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
191
195
|
}
|
|
192
196
|
throw new InvalidToken('token expired');
|
|
193
197
|
}
|
|
194
|
-
const decoded = jwt.verify(token.trim(),
|
|
198
|
+
const decoded = jwt.verify(token.trim(), AUTH_SECRET);
|
|
195
199
|
if (typeof decoded === 'string') {
|
|
196
200
|
throw new Error('Failed to decode token');
|
|
197
201
|
}
|
|
@@ -206,12 +210,8 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
206
210
|
}
|
|
207
211
|
};
|
|
208
212
|
}
|
|
209
|
-
async function
|
|
210
|
-
if (!emailAndPassword) {
|
|
211
|
-
throw new Error('Not applicable for login method');
|
|
212
|
-
}
|
|
213
|
+
async function createTemporaryToken(user) {
|
|
213
214
|
try {
|
|
214
|
-
const user = await emailAndPassword(email, skipPasswordCheck);
|
|
215
215
|
const temporaryToken = crypto.randomBytes(32).toString('hex');
|
|
216
216
|
await redis.set(temporaryToken, user.email, {
|
|
217
217
|
EX: TEMP_TOKEN_EXIRES_IN,
|
|
@@ -227,7 +227,7 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
|
|
|
227
227
|
createRouter,
|
|
228
228
|
useCurrentUser,
|
|
229
229
|
middleware,
|
|
230
|
-
|
|
230
|
+
createTemporaryToken,
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
233
|
/**
|
package/dist/faktory.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare function createJobs({ queueName }: JobsOptions): {
|
|
|
11
11
|
};
|
|
12
12
|
queueName: string;
|
|
13
13
|
};
|
|
14
|
-
export type
|
|
14
|
+
export type Job = ReturnType<ReturnType<typeof createJobs>['job']>;
|
|
15
15
|
export interface FaktoryWorkerOptions {
|
|
16
16
|
jobs: unknown[];
|
|
17
17
|
queueName: string;
|
package/dist/faktory.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'async_hooks';
|
|
|
2
2
|
import { randomUUID } from 'crypto';
|
|
3
3
|
import { create } from 'faktory-worker';
|
|
4
4
|
import { getLogger } from './logger.js';
|
|
5
|
+
import { startTimer } from './utils.js';
|
|
5
6
|
const logger = getLogger('faktory');
|
|
6
7
|
const JOB_ID = 'jobId';
|
|
7
8
|
export function createJobs({ queueName }) {
|
|
@@ -29,7 +30,7 @@ export function createJobs({ queueName }) {
|
|
|
29
30
|
await client.close();
|
|
30
31
|
}
|
|
31
32
|
catch (error) {
|
|
32
|
-
logger.error(error, '
|
|
33
|
+
logger.error(error, 'failed to create job', {
|
|
33
34
|
jobId: jid,
|
|
34
35
|
jobName,
|
|
35
36
|
args: JSON.stringify(args),
|
|
@@ -72,15 +73,9 @@ export async function createFaktoryWorker({ queueName, jobs, }) {
|
|
|
72
73
|
queueName,
|
|
73
74
|
availableJobs: availableJobs.map((j) => j.jobName),
|
|
74
75
|
});
|
|
75
|
-
const getDurationInMilliseconds = (start) => {
|
|
76
|
-
const NS_PER_SEC = 1e9;
|
|
77
|
-
const NS_TO_S = 1e9;
|
|
78
|
-
const diff = process.hrtime(start);
|
|
79
|
-
return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_S;
|
|
80
|
-
};
|
|
81
76
|
faktory.use(async (ctx, next) => {
|
|
82
77
|
const jobId = ctx.job.jid;
|
|
83
|
-
const
|
|
78
|
+
const timer = startTimer();
|
|
84
79
|
await context.run(new Map(), async () => {
|
|
85
80
|
const store = context.getStore();
|
|
86
81
|
if (store) {
|
|
@@ -88,7 +83,7 @@ export async function createFaktoryWorker({ queueName, jobs, }) {
|
|
|
88
83
|
}
|
|
89
84
|
await next();
|
|
90
85
|
});
|
|
91
|
-
const duration =
|
|
86
|
+
const duration = timer.getDuration();
|
|
92
87
|
logger.info('job succeeded', {
|
|
93
88
|
jobName: ctx.job.jobtype,
|
|
94
89
|
jobId,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -11,5 +11,5 @@ export * from './redis.js';
|
|
|
11
11
|
export * from './routes.js';
|
|
12
12
|
export * from './sentry.js';
|
|
13
13
|
export * from './slack.js';
|
|
14
|
+
export * from './utils.js';
|
|
14
15
|
export * from './validate.js';
|
|
15
|
-
export const envPort = (defaultPort = 3000) => parseInt(process.env.PORT, 10) || defaultPort;
|
package/dist/listen.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createTerminus } from '@godaddy/terminus';
|
|
2
2
|
import { getLogger } from './logger.js';
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
4
|
+
const logger = getLogger('http');
|
|
5
|
+
const READINESS_PERIOD_MS = readEnv.number('READINESS_PERIOD_MS', 0);
|
|
6
|
+
const FORCE_EXIT_TIMEOUT = readEnv.number('FORCE_EXIT_TIMEOUT', 5000);
|
|
6
7
|
/**
|
|
7
8
|
* Listen on the given port and setup proper signal
|
|
8
9
|
* handlers for terminating the process properly.
|
package/dist/middleware.d.ts
CHANGED
|
@@ -29,4 +29,4 @@ export declare function after(): RequestHandler;
|
|
|
29
29
|
/**
|
|
30
30
|
* Alias of `route()`
|
|
31
31
|
*/
|
|
32
|
-
export declare function asyncMiddleware(fn: RequestHandler): RequestHandler
|
|
32
|
+
export declare function asyncMiddleware<RequestBody, RequestQuery = void>(fn: RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>): RequestHandler<Record<string, string>, any, RequestBody, RequestQuery>;
|
package/dist/middleware.js
CHANGED
|
@@ -6,9 +6,10 @@ import helmet from 'helmet';
|
|
|
6
6
|
import { withRequestContext } from './context.js';
|
|
7
7
|
import { NotFound } from './errors.js';
|
|
8
8
|
import { getLogger } from './logger.js';
|
|
9
|
+
import { startTimer } from './utils.js';
|
|
9
10
|
export const urlencodedParser = bodyParser.urlencoded;
|
|
10
11
|
export const jsonParser = bodyParser.json;
|
|
11
|
-
const logger = getLogger('
|
|
12
|
+
const logger = getLogger('http');
|
|
12
13
|
export { asyncMiddleware as route };
|
|
13
14
|
/**
|
|
14
15
|
* Register middleware that must come before the app routes.
|
|
@@ -19,7 +20,6 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
19
20
|
logRequestDuration(),
|
|
20
21
|
Sentry.Handlers.requestHandler(),
|
|
21
22
|
Sentry.Handlers.tracingHandler(),
|
|
22
|
-
analyticsProxy(),
|
|
23
23
|
useHelmet &&
|
|
24
24
|
helmet({
|
|
25
25
|
contentSecurityPolicy: {
|
|
@@ -44,17 +44,12 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
44
44
|
}),
|
|
45
45
|
useJson && jsonParser(),
|
|
46
46
|
cookies && cookieParser(),
|
|
47
|
+
analyticsProxy(),
|
|
47
48
|
].filter(Boolean));
|
|
48
49
|
}
|
|
49
50
|
function logRequestDuration() {
|
|
50
|
-
const getDurationInMilliseconds = (start) => {
|
|
51
|
-
const NS_PER_SEC = 1e9;
|
|
52
|
-
const NS_TO_S = 1e9;
|
|
53
|
-
const diff = process.hrtime(start);
|
|
54
|
-
return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_S;
|
|
55
|
-
};
|
|
56
51
|
return (req, res, next) => {
|
|
57
|
-
const
|
|
52
|
+
const timer = startTimer();
|
|
58
53
|
logger.info('http request', {
|
|
59
54
|
method: req.method,
|
|
60
55
|
url: req.originalUrl,
|
|
@@ -62,7 +57,7 @@ function logRequestDuration() {
|
|
|
62
57
|
ip: req.ip,
|
|
63
58
|
});
|
|
64
59
|
res.on('finish', () => {
|
|
65
|
-
const duration =
|
|
60
|
+
const duration = timer.getDuration();
|
|
66
61
|
logger.info('http response', {
|
|
67
62
|
method: req.method,
|
|
68
63
|
url: req.originalUrl,
|
package/dist/migrate.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ interface MigrationFileAPI {
|
|
|
10
10
|
down(fn: any): Promise<string> | Promise<void> | string | void;
|
|
11
11
|
}
|
|
12
12
|
export declare function cli(): Promise<void>;
|
|
13
|
-
interface MigratorOptions {
|
|
13
|
+
export interface MigratorOptions {
|
|
14
14
|
postgres?: ReturnType<typeof createPostgres>;
|
|
15
15
|
migrationTableName?: string;
|
|
16
16
|
verbose?: boolean;
|
|
@@ -23,8 +23,9 @@ export declare class Migrator {
|
|
|
23
23
|
private filenameRegExp;
|
|
24
24
|
private verbose;
|
|
25
25
|
constructor({ postgres, pathToMigrations, migrationTableName, verbose, }?: MigratorOptions);
|
|
26
|
-
init(): Promise<
|
|
27
|
-
|
|
26
|
+
init(): Promise<boolean>;
|
|
27
|
+
isInstalled(): Promise<boolean>;
|
|
28
|
+
create(name: string | null | undefined, format?: 'ts' | 'sql'): Promise<string>;
|
|
28
29
|
runMigrations(direction: 'up' | 'down'): Promise<number>;
|
|
29
30
|
log(...args: any[]): void;
|
|
30
31
|
executeMigration(migrations: Migration[], currentVersion: number, direction: 'up' | 'down'): Promise<number>;
|
package/dist/migrate.js
CHANGED
|
@@ -9,7 +9,7 @@ Usage
|
|
|
9
9
|
$ migrate init
|
|
10
10
|
$ migrate up
|
|
11
11
|
$ migrate down
|
|
12
|
-
$ migrate create <name>
|
|
12
|
+
$ migrate create <name> [--typescript]
|
|
13
13
|
`, {
|
|
14
14
|
description: 'PostgreSQL migration tool',
|
|
15
15
|
importMeta: import.meta,
|
|
@@ -32,12 +32,18 @@ Usage
|
|
|
32
32
|
case 'down':
|
|
33
33
|
await migrator.runMigrations(action);
|
|
34
34
|
break;
|
|
35
|
-
case 'init':
|
|
36
|
-
await migrator.init();
|
|
37
|
-
|
|
35
|
+
case 'init': {
|
|
36
|
+
const created = await migrator.init();
|
|
37
|
+
if (created) {
|
|
38
|
+
migrator.log(chalk.green('Migrations table was successfully setup.'));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
migrator.log(chalk.green('Migrations table already existing.'));
|
|
42
|
+
}
|
|
38
43
|
break;
|
|
44
|
+
}
|
|
39
45
|
case 'create': {
|
|
40
|
-
const createdMigration = await migrator.create(args[0]);
|
|
46
|
+
const createdMigration = await migrator.create(args[0], cli.flags.typescript ? 'ts' : 'sql');
|
|
41
47
|
migrator.log(chalk.green('Created migration', createdMigration));
|
|
42
48
|
break;
|
|
43
49
|
}
|
|
@@ -61,15 +67,30 @@ export class Migrator {
|
|
|
61
67
|
this.verbose = verbose;
|
|
62
68
|
}
|
|
63
69
|
async init() {
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
if (await this.isInstalled()) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
await fs.mkdir(this.pathToMigrations, { recursive: true });
|
|
74
|
+
await this.postgres.query(`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS ${this.migrationTableName} (
|
|
66
76
|
id serial PRIMARY KEY,
|
|
67
77
|
version int NOT NULL,
|
|
68
78
|
run_at timestamp NOT NULL
|
|
69
79
|
);
|
|
70
80
|
`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
async isInstalled() {
|
|
84
|
+
try {
|
|
85
|
+
await this.postgres.query(`SELECT * FROM ${this.migrationTableName}`);
|
|
86
|
+
await fs.stat(this.pathToMigrations);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
71
92
|
}
|
|
72
|
-
async create(name) {
|
|
93
|
+
async create(name, format = 'sql') {
|
|
73
94
|
if (!name) {
|
|
74
95
|
throw new Error('Missing positional argument <name>');
|
|
75
96
|
}
|
|
@@ -77,15 +98,25 @@ export class Migrator {
|
|
|
77
98
|
const lastVersion = existingMigrations.length
|
|
78
99
|
? existingMigrations[existingMigrations.length - 1].version
|
|
79
100
|
: 0;
|
|
80
|
-
const filename = path.join(this.pathToMigrations, `${lastVersion + 1}_${name}
|
|
81
|
-
const
|
|
82
|
-
|
|
101
|
+
const filename = path.join(this.pathToMigrations, `${lastVersion + 1}_${name}.${format}`);
|
|
102
|
+
const getTemplate = () => {
|
|
103
|
+
switch (format) {
|
|
104
|
+
case 'sql':
|
|
105
|
+
return `-- Migration ${name} created ${new Date()}\n\nSELECT 1;\n\n-- ROLLBACK --\n\nSELECT 1;\n`;
|
|
106
|
+
case 'ts':
|
|
107
|
+
return `// Migration ${name} created ${new Date()}\n\nexport function up(query: any) {}\n\nexport function down(query: any) {}\n`;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
await fs.writeFile(filename, getTemplate());
|
|
83
111
|
return filename;
|
|
84
112
|
}
|
|
85
113
|
async runMigrations(direction) {
|
|
86
114
|
if (direction !== 'up' && direction !== 'down') {
|
|
87
115
|
throw new Error('Only up and down are supported.');
|
|
88
116
|
}
|
|
117
|
+
if (!(await this.isInstalled())) {
|
|
118
|
+
await this.init();
|
|
119
|
+
}
|
|
89
120
|
const migrations = await this.getMigrations();
|
|
90
121
|
const currentVersion = await this.getCurrentVersion();
|
|
91
122
|
return this.executeMigration(migrations, currentVersion, direction);
|
|
@@ -159,9 +190,9 @@ export class Migrator {
|
|
|
159
190
|
return import(filename);
|
|
160
191
|
}
|
|
161
192
|
const content = await fs.readFile(filename, 'utf-8');
|
|
162
|
-
const [up, down] = content.split(
|
|
193
|
+
const [up, down] = content.split(/[-\s]+ROLLBACK[-\s]+/i);
|
|
163
194
|
return {
|
|
164
|
-
useTransaction:
|
|
195
|
+
useTransaction: !/[-\s]+NO[\s]*TRANSACTION[-\s]+/i.test(content),
|
|
165
196
|
up() {
|
|
166
197
|
return up.trim();
|
|
167
198
|
},
|
package/dist/postgres.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import pg, { type PoolClient, type QueryResult, type QueryResultRow } from 'pg';
|
|
|
2
2
|
import { SQL, type SQLStatement } from 'sql-template-strings';
|
|
3
3
|
export interface PostgresOptions extends pg.PoolConfig {
|
|
4
4
|
onError?: (error: Error, client: any) => void;
|
|
5
|
+
url?: pg.PoolConfig['connectionString'];
|
|
5
6
|
}
|
|
6
7
|
export declare class DoesNotExistError extends Error {
|
|
7
8
|
constructor(message: string);
|
|
@@ -31,7 +32,7 @@ export declare class Transaction extends Connection {
|
|
|
31
32
|
*
|
|
32
33
|
* @param config
|
|
33
34
|
*/
|
|
34
|
-
export declare function createPostgres({ onError, ...config }?: PostgresOptions): {
|
|
35
|
+
export declare function createPostgres({ onError, url, ...config }?: PostgresOptions): {
|
|
35
36
|
insert: <T extends pg.QueryResultRow>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<pg.QueryResult<T>>;
|
|
36
37
|
query: <T_1 extends pg.QueryResultRow>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<pg.QueryResult<T_1>>;
|
|
37
38
|
first: <T_2>(text: string | SQLStatement, values?: Value[] | undefined) => Promise<T_2 | null>;
|
package/dist/postgres.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { SQL } from 'sql-template-strings';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
3
4
|
export class DoesNotExistError extends Error {
|
|
4
5
|
constructor(message) {
|
|
5
6
|
super(message);
|
|
@@ -67,8 +68,8 @@ export class Transaction extends Connection {
|
|
|
67
68
|
*
|
|
68
69
|
* @param config
|
|
69
70
|
*/
|
|
70
|
-
export function createPostgres({ onError = () => { }, ...config } = {}) {
|
|
71
|
-
const pool = new pg.Pool(config);
|
|
71
|
+
export function createPostgres({ onError = () => { }, url = readEnv('POSTGRES_URL'), ...config } = {}) {
|
|
72
|
+
const pool = new pg.Pool({ connectionString: url, ...config });
|
|
72
73
|
pool.on('error', onError);
|
|
73
74
|
async function withConnection(callback) {
|
|
74
75
|
const client = await pool.connect();
|
package/dist/redis.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createClient } from 'redis';
|
|
2
|
-
|
|
2
|
+
import { readEnv } from './utils.js';
|
|
3
|
+
export function createRedis({ url = readEnv('REDIS_URL', ''), onError, }) {
|
|
3
4
|
const client = createClient({ url });
|
|
4
5
|
client.on('error', onError);
|
|
5
6
|
return client;
|
package/dist/sentry.d.ts
CHANGED
|
@@ -58,6 +58,7 @@ export declare function createSentryOptions(app: Application | null, options?: S
|
|
|
58
58
|
profilesSampler?: ((samplingContext: import("@sentry/types").SamplingContext) => number | boolean) | undefined;
|
|
59
59
|
serverName?: string | undefined;
|
|
60
60
|
includeLocalVariables?: boolean | undefined;
|
|
61
|
+
clientClass?: typeof Sentry.NodeClient | undefined;
|
|
61
62
|
shouldCreateSpanForRequest?(this: void, url: string): boolean;
|
|
62
63
|
onFatalError?(this: void, error: Error): void;
|
|
63
64
|
};
|
package/dist/sentry.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import * as Sentry from '@sentry/node';
|
|
2
|
+
import { attachSentryClientToLoggers } from './logger.js';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
2
4
|
/**
|
|
3
5
|
* Create a pre-configured Sentry client
|
|
4
6
|
*/
|
|
5
7
|
export function createSentry(options = {}) {
|
|
8
|
+
const client = Sentry;
|
|
6
9
|
return {
|
|
7
|
-
client
|
|
10
|
+
client,
|
|
8
11
|
init(app, getOptions = (defaultOptions) => defaultOptions) {
|
|
12
|
+
attachSentryClientToLoggers(client);
|
|
9
13
|
Sentry.init(getOptions(createSentryOptions(app, options)));
|
|
10
14
|
},
|
|
11
15
|
};
|
|
@@ -15,7 +19,7 @@ export function createSentry(options = {}) {
|
|
|
15
19
|
*/
|
|
16
20
|
export function createSentryOptions(app, options = {}) {
|
|
17
21
|
return {
|
|
18
|
-
dsn:
|
|
22
|
+
dsn: readEnv('SENTRY_DSN'),
|
|
19
23
|
integrations: [
|
|
20
24
|
new Sentry.Integrations.Http({ tracing: true }),
|
|
21
25
|
app && new Sentry.Integrations.Express({ app }),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import createFetchMock from 'vitest-fetch-mock';
|
|
5
|
+
import { vi } from 'vitest';
|
|
6
|
+
const fetchMocker = createFetchMock(vi);
|
|
7
|
+
fetchMocker.enableMocks();
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
globalThis.crypto = crypto;
|
package/dist/slack.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createFetch } from '@devmoods/fetch';
|
|
2
2
|
import { getLogger } from './logger.js';
|
|
3
|
+
import { readEnv } from './utils.js';
|
|
3
4
|
const logger = getLogger('slack');
|
|
4
5
|
/**
|
|
5
6
|
* Send messages to Slack using Incoming Webhooks
|
|
@@ -14,7 +15,7 @@ const logger = getLogger('slack');
|
|
|
14
15
|
*
|
|
15
16
|
* See https://api.slack.com/messaging/webhooks
|
|
16
17
|
*/
|
|
17
|
-
export function createSlack({ webhookUrl =
|
|
18
|
+
export function createSlack({ webhookUrl = readEnv('SLACK_WEBHOOK_URL', ''), dryRun = process.env.NODE_ENV !== 'production', } = {}) {
|
|
18
19
|
const fetch = createFetch();
|
|
19
20
|
/**
|
|
20
21
|
* Post a message to Slack
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a high resolution timer
|
|
3
|
+
*
|
|
4
|
+
*/
|
|
5
|
+
export declare function startTimer(): {
|
|
6
|
+
getDuration(): number;
|
|
7
|
+
};
|
|
8
|
+
export declare function readEnv(key: string): string | undefined;
|
|
9
|
+
export declare function readEnv(key: string, defaultValue: string): string;
|
|
10
|
+
export declare namespace readEnv {
|
|
11
|
+
var number: typeof readEnvNumber;
|
|
12
|
+
var boolean: typeof readEnvBoolean;
|
|
13
|
+
}
|
|
14
|
+
declare function readEnvNumber(key: string, defaultValue: number): number;
|
|
15
|
+
declare function readEnvBoolean(key: string, defaultValue: boolean): boolean;
|
|
16
|
+
export declare const envPort: (defaultPort?: number) => number;
|
|
17
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start a high resolution timer
|
|
3
|
+
*
|
|
4
|
+
*/
|
|
5
|
+
export function startTimer() {
|
|
6
|
+
const start = process.hrtime();
|
|
7
|
+
return {
|
|
8
|
+
getDuration() {
|
|
9
|
+
const elapsed = process.hrtime(start);
|
|
10
|
+
return (elapsed[0] * 1e9 + elapsed[1]) / 1e9;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function readEnv(key, defaultValue) {
|
|
15
|
+
const value = process.env[key];
|
|
16
|
+
return value ?? defaultValue;
|
|
17
|
+
}
|
|
18
|
+
readEnv.number = readEnvNumber;
|
|
19
|
+
readEnv.boolean = readEnvBoolean;
|
|
20
|
+
function readEnvNumber(key, defaultValue) {
|
|
21
|
+
const str = readEnv(key, `${defaultValue}`);
|
|
22
|
+
return parseFloat(str);
|
|
23
|
+
}
|
|
24
|
+
function readEnvBoolean(key, defaultValue) {
|
|
25
|
+
return ['true', '1', 't'].includes(readEnv(key, `${defaultValue}`).toLowerCase());
|
|
26
|
+
}
|
|
27
|
+
export const envPort = (defaultPort = 3000) => readEnv.number('PORT', defaultPort);
|
package/dist/validate.d.ts
CHANGED
package/dist/validate.js
CHANGED
|
@@ -45,7 +45,7 @@ export function compileValidateSchema(schema, options = {}) {
|
|
|
45
45
|
errors: null,
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
const errors = transformAjvErrors(validate.errors)
|
|
48
|
+
const errors = transformAjvErrors(validate.errors);
|
|
49
49
|
validate.errors = null;
|
|
50
50
|
return {
|
|
51
51
|
isValid: false,
|
|
@@ -65,7 +65,7 @@ export function validateSchema(data, schema, options = {}) {
|
|
|
65
65
|
errors: null,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
-
const errors = transformAjvErrors(ajv.errors)
|
|
68
|
+
const errors = transformAjvErrors(ajv.errors);
|
|
69
69
|
ajv.errors = null;
|
|
70
70
|
return {
|
|
71
71
|
isValid: false,
|
|
@@ -73,8 +73,9 @@ export function validateSchema(data, schema, options = {}) {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
function transformAjvErrors(errors) {
|
|
76
|
+
/* c8 ignore next 3 */
|
|
76
77
|
if (errors == null) {
|
|
77
|
-
return
|
|
78
|
+
return {};
|
|
78
79
|
}
|
|
79
80
|
return errors
|
|
80
81
|
.map((error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -24,12 +24,14 @@
|
|
|
24
24
|
"license": "ISC",
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@devmoods/eslint-config": "^1.2.0",
|
|
27
|
-
"@sentry/types": "^7.
|
|
27
|
+
"@sentry/types": "^7.61.1",
|
|
28
28
|
"@types/express-serve-static-core": "^4.17.35",
|
|
29
|
-
"@types/node": "20.4.
|
|
30
|
-
"@types/supertest": "2.0.12",
|
|
31
|
-
"@typescript-eslint/eslint-plugin": "^6.
|
|
32
|
-
"@typescript-eslint/parser": "^6.
|
|
29
|
+
"@types/node": "20.4.8",
|
|
30
|
+
"@types/supertest": "^2.0.12",
|
|
31
|
+
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
|
32
|
+
"@typescript-eslint/parser": "^6.3.0",
|
|
33
|
+
"@vitest/coverage-v8": "^0.34.1",
|
|
34
|
+
"@vitest/expect": "^0.34.1",
|
|
33
35
|
"chalk": "^5.3.0",
|
|
34
36
|
"eslint": "^8.46.0",
|
|
35
37
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
@@ -39,17 +41,17 @@
|
|
|
39
41
|
"faktory-worker": "^4.5.1",
|
|
40
42
|
"import-sort-cli": "6.0.0",
|
|
41
43
|
"import-sort-style-module": "6.0.0",
|
|
42
|
-
"
|
|
43
|
-
"prettier": "3.0.0",
|
|
44
|
+
"prettier": "3.0.1",
|
|
44
45
|
"supertest": "6.3.3",
|
|
45
46
|
"ts-node": "10.9.1",
|
|
46
47
|
"typescript": "5.1.6",
|
|
47
|
-
"vitest": "^0.
|
|
48
|
+
"vitest": "^0.34.1",
|
|
49
|
+
"vitest-fetch-mock": "^0.2.2"
|
|
48
50
|
},
|
|
49
51
|
"dependencies": {
|
|
50
52
|
"@devmoods/fetch": "^3.3.0",
|
|
51
53
|
"@godaddy/terminus": "^4.12.1",
|
|
52
|
-
"@sentry/node": "^7.
|
|
54
|
+
"@sentry/node": "^7.61.1",
|
|
53
55
|
"@types/bcrypt": "^5.0.0",
|
|
54
56
|
"@types/body-parser": "^1.19.2",
|
|
55
57
|
"@types/cookie-parser": "^1.4.3",
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
"@types/koa-compose": "^3.2.5",
|
|
59
61
|
"@types/nodemailer": "^6.4.9",
|
|
60
62
|
"@types/pg": "^8.10.2",
|
|
63
|
+
"@types/superagent": "^4.1.18",
|
|
61
64
|
"@types/uuid": "^9.0.2",
|
|
62
65
|
"ajv": "^8.12.0",
|
|
63
66
|
"bcrypt": "^5.1.0",
|
|
@@ -71,7 +74,7 @@
|
|
|
71
74
|
"jsonwebtoken": "^9.0.1",
|
|
72
75
|
"meow": "^12.0.1",
|
|
73
76
|
"nodemailer": "^6.9.4",
|
|
74
|
-
"pg": "^8.11.
|
|
77
|
+
"pg": "^8.11.2",
|
|
75
78
|
"redis": "^4.6.7",
|
|
76
79
|
"sql-template-strings": "^2.2.2",
|
|
77
80
|
"uuid": "^9.0.0"
|
|
@@ -100,7 +103,7 @@
|
|
|
100
103
|
"start:worker": "ts-node --esm example/worker.ts",
|
|
101
104
|
"clean": "rm -rf dist",
|
|
102
105
|
"lint": "eslint src",
|
|
103
|
-
"test": "NODE_ENV=test vitest",
|
|
106
|
+
"test": "NODE_ENV=test DOTENV_CONFIG_PATH=.env.test vitest",
|
|
104
107
|
"build": "pnpm run clean && tsc --project tsconfig.build.json",
|
|
105
108
|
"prettier": "prettier --write '**/*.{js,css,md,ts,tsx}'",
|
|
106
109
|
"import-sort": "import-sort --write '**/*.{js,jsx,ts,tsx}'",
|