@devmoods/express-extras 0.21.0 → 0.22.1

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 CHANGED
@@ -2,18 +2,21 @@
2
2
 
3
3
  > Kubernetes-ready utilities for rapid Node.js application development
4
4
 
5
- _Extremely opinionated_ set of utilities for cranking out Node.js apps in a timely manner.
5
+ _Extremely opinionated_ set of utilities for cranking out production-ready Node.js apps in a timely manner. We assume you are using PostgreSQL as the main database, Redis for data you don't want in Postgres and [Faktory](https://contribsys.com/faktory/) for background jobs.
6
6
 
7
- - Express (with cool helpers for simple async/await)
8
- - PostgreSQL (w/ transactions) + Migrations
7
+ - Only works with [ECMAScript Modules (ESM)](https://nodejs.org/api/esm.html)
8
+ - Thin layer on top of [Express](https://expressjs.com) with better support for `async`/`await`
9
+ - PostgreSQL with transactions and migrations
9
10
  - Redis
10
- - Slack Notifications
11
- - JSON Schema validation of incoming requests
12
- - Logging
13
- - Graceful Shutdown
11
+ - Slack notifications
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
14
16
  - Read configuration from `.env` files
15
- - Request Context using Continuation-Local-Storage
16
- - [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807)
17
+ - Request local context using [AsyncLocalStorage](https://nodejs.org/api/async_context.html)
18
+ - [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) by default
19
+ - Works best with the [PnPM](https://pnpm.io/) package manager
17
20
 
18
21
  ## Goals
19
22
 
@@ -21,127 +24,12 @@ _Extremely opinionated_ set of utilities for cranking out Node.js apps in a time
21
24
 
22
25
  ## Non-goals
23
26
 
24
- - Be extremely flexible and configurable
27
+ - _Encourage deviating from the chosen path_
25
28
 
26
29
  ## Example
27
30
 
28
31
  > See the [example/](./example) folder for a complete example.
29
32
 
30
- ```typescript
31
- require('dotenv-safe').config({
32
- allowEmptyValues: true,
33
- });
34
-
35
- import http from 'http';
36
- import express from 'express';
37
- import * as Sentry from '@sentry/node';
38
- import helmet from 'helmet';
39
- import bodyParser from 'body-parser';
40
- import {
41
- errorHandler,
42
- envPort,
43
- validateBody,
44
- route,
45
- listen,
46
- withRequestContext,
47
- ApiError,
48
- createLogger,
49
- createPostgres,
50
- getRequestId,
51
- sql,
52
- } from '@devmoods/express-extras';
53
-
54
- const app = express();
55
- const server = http.createServer(app);
56
- const postgres = createPostgres();
57
- const logger = createLogger({
58
- name: 'example-app',
59
- getContext() {
60
- return {
61
- requestId: getRequestId(),
62
- };
63
- },
64
- });
65
- const slack = createSlack({
66
- webhookUrl: process.env.SLACK_WEBHOOK_URL!,
67
- });
68
-
69
- app.set('port', envPort());
70
- app.use(Sentry.Handlers.requestHandler());
71
- app.use(helmet());
72
- app.use(bodyParser.json());
73
- app.use(withRequestContext());
74
-
75
- app.get(
76
- '/posts',
77
- route(async () => {
78
- const posts = await postgres.query('SELECT * FROM posts LIMIT 10');
79
- return posts;
80
- }),
81
- );
82
-
83
- app.post(
84
- '/posts',
85
- validateBody({
86
- type: 'object',
87
- properties: {
88
- title: {
89
- type: 'string',
90
- minLength: 3,
91
- },
92
- content: {
93
- type: 'string',
94
- minLength: 3,
95
- },
96
- },
97
- required: ['title', 'content'],
98
- }),
99
- route(async ({ body: { title, content } }) => {
100
- const post = { title, content };
101
-
102
- await postgres.insert(
103
- sql`INSERT INTO posts (title, content) VALUES (${title}, ${content})`,
104
- );
105
-
106
- slack.postMessage(`New post created with title ${title}`);
107
-
108
- return [201, post];
109
- }),
110
- );
111
-
112
- app.use((req, res, next) => {
113
- next(NotFound());
114
- });
115
-
116
- app.use(
117
- Sentry.Handlers.errorHandler({
118
- shouldHandleError(error) {
119
- return true;
120
- },
121
- }),
122
- );
123
-
124
- app.use(
125
- errorHandler({
126
- onError(error) {
127
- if (error instanceof ApiError && error.status < 500) {
128
- logger.warn(error.message, { error });
129
- } else {
130
- logger.error(error);
131
- }
132
- },
133
- }),
134
- );
135
-
136
- listen(server, {
137
- port: app.get('port'),
138
- logger,
139
- async onShutdown() {
140
- await Promise.all([redis.quit(), postgres.end()]);
141
- },
142
- });
143
- ```
144
-
145
33
  ## Configure
146
34
 
147
35
  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`.
package/dist/auth.d.ts CHANGED
@@ -12,13 +12,15 @@ export interface AuthOptions<UserModel extends Record<string, any>> {
12
12
  tokenExpiry?: number;
13
13
  onResetRequested?(user: UserModel, temporaryToken: string): void | Promise<void>;
14
14
  onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
15
+ onUserVerified?(user: UserModel): Promise<void>;
15
16
  }
16
17
  export interface CreateAuth<UserModel extends Record<string, any>> {
17
18
  createRouter: () => Router;
18
19
  useCurrentUser: () => UserModel | undefined;
19
20
  middleware: (authorize?: boolean) => RequestHandler;
21
+ createVerificationToken(email: string): Promise<string>;
20
22
  }
21
- export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry, onResetRequested, onPasswordChanged, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
23
+ export declare function createAuth<UserModel extends Record<string, any>>({ getUserByToken, methods: { emailAndPassword }, filterUser, redis, tokenExpiry, onResetRequested, onPasswordChanged, onUserVerified, }: AuthOptions<UserModel>): CreateAuth<UserModel>;
22
24
  /**
23
25
  * Extract a Bearer token from the HTTP Authorization header
24
26
  * of `req`.
package/dist/auth.js CHANGED
@@ -14,9 +14,10 @@ const TEMP_TOKEN_EXIRES_IN = 3600;
14
14
  export function createAuthToken(payload, secret = process.env.APP_JWT_SECRET) {
15
15
  return jwt.sign(payload, secret, { expiresIn: TOKEN_EXPIRES_IN });
16
16
  }
17
- export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN, onResetRequested, onPasswordChanged, }) {
17
+ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filterUser = (x) => x, redis, tokenExpiry = TOKEN_EXPIRES_IN, onResetRequested, onPasswordChanged, onUserVerified, }) {
18
18
  const useCurrentUser = () => useContext('currentUser');
19
19
  const setCurrentUser = (user) => setContext('currentUser', user);
20
+ const skipPasswordCheck = async () => true;
20
21
  const createRouter = () => {
21
22
  const router = Router();
22
23
  router.get('/me', middleware(), route(() => {
@@ -55,27 +56,20 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
55
56
  setAccessTokenCookie(res, null);
56
57
  res.redirect('/');
57
58
  }));
58
- const skipPasswordCheck = async () => true;
59
59
  const tokenSchema = {
60
- type: 'object',
61
- properties: {
62
- token: { type: 'string', minLength: 50, maxLength: 64 },
63
- },
64
- };
65
- const emailSchema = {
66
60
  type: 'string',
67
- minLength: 4,
61
+ minLength: 50,
62
+ maxLength: 64,
68
63
  };
64
+ const passwordSchema = { type: 'string', minLength: 8 };
65
+ const emailSchema = { type: 'string', minLength: 4 };
69
66
  if (emailAndPassword) {
70
- router.post('/reset/:token', validateParams(tokenSchema), validateBody({
67
+ router.post('/change-password', validateBody({
71
68
  type: 'object',
72
- properties: {
73
- password: { type: 'string', minLength: 8 },
74
- },
75
- required: ['password'],
69
+ properties: { token: tokenSchema, password: passwordSchema },
70
+ required: ['token', 'password'],
76
71
  }), route(async (req) => {
77
- const { password } = req.body;
78
- const { token } = req.params;
72
+ const { password, token } = req.body;
79
73
  const email = await redis.get(token);
80
74
  if (!email) {
81
75
  logger.info('invalid reset token', { token });
@@ -88,9 +82,41 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
88
82
  logger.info('user changed password');
89
83
  return 200;
90
84
  }));
85
+ router.post('/verify-email', validateBody({
86
+ type: 'object',
87
+ properties: { token: tokenSchema, email: emailSchema },
88
+ required: ['token', 'email'],
89
+ }), route(async (req) => {
90
+ const { token } = req.body;
91
+ const email = await redis.get(token);
92
+ if (!email) {
93
+ logger.info('invalid verification token', { token });
94
+ throw BadRequest('invalid verification token');
95
+ }
96
+ if (email !== req.body.email) {
97
+ logger.info('email verification mismatch', {
98
+ expected: email,
99
+ actual: req.body.email,
100
+ });
101
+ throw BadRequest('invalid verification token');
102
+ }
103
+ const user = await emailAndPassword(email, skipPasswordCheck);
104
+ setCurrentUser(user);
105
+ await onUserVerified?.(user);
106
+ await redis.del(token);
107
+ logger.info('user verified');
108
+ return 200;
109
+ }));
91
110
  }
92
- router.get('/reset/:token', validateParams(tokenSchema), route(async (req) => {
93
- const token = req.params.token;
111
+ /**
112
+ * Check that a reset token is valid.
113
+ */
114
+ router.get('/reset/:token', validateParams({
115
+ type: 'object',
116
+ properties: { token: tokenSchema },
117
+ required: ['token'],
118
+ }), route(async (req) => {
119
+ const { token } = req.params;
94
120
  const email = await redis.get(token);
95
121
  if (!email) {
96
122
  logger.info('invalid reset token', { token });
@@ -98,11 +124,12 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
98
124
  }
99
125
  return 200;
100
126
  }));
127
+ /**
128
+ * Request password reset
129
+ */
101
130
  router.post('/reset', middleware(false), validateBody({
102
131
  type: 'object',
103
- properties: {
104
- email: emailSchema,
105
- },
132
+ properties: { email: emailSchema },
106
133
  required: ['email'],
107
134
  }), route(async (req) => {
108
135
  const email = req.body.email;
@@ -170,10 +197,28 @@ export function createAuth({ getUserByToken, methods: { emailAndPassword }, filt
170
197
  }
171
198
  };
172
199
  }
200
+ async function createVerificationToken(email) {
201
+ if (!emailAndPassword) {
202
+ throw new Error('Not applicable for login method');
203
+ }
204
+ try {
205
+ const user = await emailAndPassword(email, skipPasswordCheck);
206
+ const temporaryToken = crypto.randomBytes(32).toString('hex');
207
+ await redis.set(temporaryToken, user.email, {
208
+ EX: TEMP_TOKEN_EXIRES_IN,
209
+ });
210
+ return temporaryToken;
211
+ }
212
+ catch (error) {
213
+ logger.error(error, 'could not create verification token');
214
+ throw error;
215
+ }
216
+ }
173
217
  return {
174
218
  createRouter,
175
219
  useCurrentUser,
176
220
  middleware,
221
+ createVerificationToken,
177
222
  };
178
223
  }
179
224
  /**
package/dist/context.d.ts CHANGED
@@ -13,5 +13,5 @@ export declare function useContext<T>(key: string): T | undefined;
13
13
  /**
14
14
  * Set a value on the request context
15
15
  */
16
- export declare function setContext<T>(key: string, value: T): Map<string, any> | undefined;
16
+ export declare function setContext<T>(key: string, value: T): void;
17
17
  export declare function useRequestId(): string | undefined;
package/dist/context.js CHANGED
@@ -32,7 +32,7 @@ export function useContext(key) {
32
32
  export function setContext(key, value) {
33
33
  const store = requestContext.getStore();
34
34
  if (store) {
35
- return store.set(key, value);
35
+ store.set(key, value);
36
36
  }
37
37
  }
38
38
  export function useRequestId() {
@@ -0,0 +1,22 @@
1
+ export interface JobsOptions {
2
+ queueName: string;
3
+ }
4
+ export declare function createJobs({ queueName }: JobsOptions): {
5
+ job: <K extends Record<string, any>, T extends (input: K) => void | Promise<any>>(fn: T) => {
6
+ $job: boolean;
7
+ jobName: string;
8
+ readonly executionCount: number;
9
+ delay(args?: Parameters<T>[0] | undefined): Promise<void>;
10
+ execute(args: Parameters<T>[0]): Promise<any>;
11
+ };
12
+ queueName: string;
13
+ };
14
+ export type Task = ReturnType<ReturnType<typeof createJobs>['job']>;
15
+ export interface FaktoryWorkerOptions {
16
+ jobs: unknown[];
17
+ queueName: string;
18
+ }
19
+ export declare function useJobId(): string | undefined;
20
+ export declare function createFaktoryWorker({ queueName, jobs, }: FaktoryWorkerOptions): Promise<{
21
+ start(): Promise<void>;
22
+ }>;
@@ -0,0 +1,108 @@
1
+ import { AsyncLocalStorage } from 'async_hooks';
2
+ import { randomUUID } from 'crypto';
3
+ import { create } from 'faktory-worker';
4
+ import { getLogger } from './logger.js';
5
+ const logger = getLogger('faktory');
6
+ const JOB_ID = 'jobId';
7
+ export function createJobs({ queueName }) {
8
+ const faktory = create();
9
+ function job(fn) {
10
+ const jobName = fn.name;
11
+ let executionCount = 0;
12
+ if (jobName == null) {
13
+ throw new Error('Can not create job without a name');
14
+ }
15
+ return {
16
+ $job: true,
17
+ jobName,
18
+ get executionCount() {
19
+ return executionCount;
20
+ },
21
+ async delay(args) {
22
+ const jid = randomUUID();
23
+ try {
24
+ const client = await faktory.connect();
25
+ const j = client.job(jobName, args);
26
+ j.jid = jid;
27
+ j.queue = queueName;
28
+ await j.push();
29
+ await client.close();
30
+ }
31
+ catch (error) {
32
+ logger.error(error, 'Failed to create job', {
33
+ jobId: jid,
34
+ jobName,
35
+ args: JSON.stringify(args),
36
+ });
37
+ }
38
+ },
39
+ async execute(args) {
40
+ try {
41
+ const result = await fn(args);
42
+ executionCount += 1;
43
+ return result;
44
+ }
45
+ catch (error) {
46
+ logger.error(error);
47
+ throw error;
48
+ }
49
+ },
50
+ };
51
+ }
52
+ return { job, queueName };
53
+ }
54
+ const context = new AsyncLocalStorage();
55
+ export function useJobId() {
56
+ const store = context.getStore();
57
+ if (store) {
58
+ return store.get(JOB_ID);
59
+ }
60
+ }
61
+ export async function createFaktoryWorker({ queueName, jobs, }) {
62
+ const faktory = create();
63
+ const filter = (j) => !!Object.prototype.hasOwnProperty.call(j, '$job');
64
+ const availableJobs = jobs.filter(filter);
65
+ for (const job of availableJobs) {
66
+ faktory.register(job.jobName, job.execute);
67
+ }
68
+ return {
69
+ async start() {
70
+ try {
71
+ logger.info('faktory started', {
72
+ queueName,
73
+ availableJobs: availableJobs.map((j) => j.jobName),
74
+ });
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
+ faktory.use(async (ctx, next) => {
82
+ const jobId = ctx.job.jid;
83
+ const start = process.hrtime();
84
+ await context.run(new Map(), async () => {
85
+ const store = context.getStore();
86
+ if (store) {
87
+ store.set(JOB_ID, jobId);
88
+ }
89
+ await next();
90
+ });
91
+ const duration = getDurationInMilliseconds(start);
92
+ logger.info('job succeeded', {
93
+ jobName: ctx.job.jobtype,
94
+ jobId,
95
+ duration,
96
+ });
97
+ });
98
+ await faktory.work({
99
+ queues: [queueName],
100
+ });
101
+ }
102
+ catch (error) {
103
+ logger.error(error);
104
+ process.exit(1);
105
+ }
106
+ },
107
+ };
108
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './auth.js';
2
2
  export * from './context.js';
3
3
  export * from './email.js';
4
4
  export * from './errors.js';
5
+ export * from './faktory.js';
5
6
  export * from './listen.js';
6
7
  export * from './logger.js';
7
8
  export * from './middleware.js';
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from './auth.js';
2
2
  export * from './context.js';
3
3
  export * from './email.js';
4
4
  export * from './errors.js';
5
+ export * from './faktory.js';
5
6
  export * from './listen.js';
6
7
  export * from './logger.js';
7
8
  export * from './middleware.js';
package/dist/logger.js CHANGED
@@ -43,6 +43,7 @@ export function createLogger({ name, getContext, silent = () => process.env.NODE
43
43
  level: LEVELS_TO_NAME[level],
44
44
  hostname,
45
45
  pid,
46
+ name,
46
47
  message,
47
48
  ...context,
48
49
  time: new Date().toISOString(),
@@ -1,6 +1,5 @@
1
1
  import bodyParser from 'body-parser';
2
2
  import type { RequestHandler } from 'express';
3
- import { type ErrorHandlerOptions } from './errors.js';
4
3
  type UrlencodedParser = typeof bodyParser.urlencoded;
5
4
  type JsonParser = typeof bodyParser.json;
6
5
  export declare const urlencodedParser: UrlencodedParser;
@@ -23,17 +22,10 @@ export declare function before({ cookies, helmet: useHelmet, json: useJson, allo
23
22
  * unknown routes.
24
23
  */
25
24
  export declare function notFound(): RequestHandler;
26
- export interface AfterMiddlewareOptions {
27
- onError?: ErrorHandlerOptions['onError'];
28
- }
29
25
  /**
30
26
  * Register middlewares that must come after the app routes.
31
- *
32
- * Currently attaches:
33
- * - notFound()
34
27
  */
35
- type After = ReturnType<typeof notFound>;
36
- export declare function after(): After;
28
+ export declare function after(): RequestHandler;
37
29
  /**
38
30
  * Alias of `route()`
39
31
  */
@@ -84,8 +84,25 @@ export function notFound() {
84
84
  next(NotFound(`Could not ${req.method} ${req.path}`));
85
85
  };
86
86
  }
87
+ /**
88
+ * In production, we could directly proxy the url
89
+ * to an analytics service from nginx, but in dev
90
+ * we serve a dummy script to avoid 404.
91
+ */
92
+ function analyticsProxy() {
93
+ return (req, res, next) => {
94
+ if (req.originalUrl === '/analytics.js') {
95
+ res.setHeader('Content-type', 'text/javascript');
96
+ return res.status(200).end('// no-op analytics proxy');
97
+ }
98
+ next();
99
+ };
100
+ }
101
+ /**
102
+ * Register middlewares that must come after the app routes.
103
+ */
87
104
  export function after() {
88
- return notFound();
105
+ return compose([analyticsProxy(), notFound()]);
89
106
  }
90
107
  /**
91
108
  * Alias of `route()`
package/dist/routes.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import type { RequestHandler, Express } from 'express';
1
+ import type { Express, RequestHandler } from 'express';
2
2
  export declare function createDocsRouter(app: Express, extras?: Record<string, any>): RequestHandler;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,17 +8,6 @@
8
8
  "bin": {
9
9
  "migrate": "bin/migrate"
10
10
  },
11
- "scripts": {
12
- "start": "ts-node --esm example/index.ts",
13
- "clean": "rm -rf dist",
14
- "lint": "eslint src",
15
- "test": "NODE_ENV=test vitest",
16
- "build": "pnpm run clean && tsc --project tsconfig.build.json",
17
- "prepublishOnly": "pnpm run clean && pnpm run build",
18
- "prettier": "prettier --write '**/*.{js,css,md,ts,tsx}'",
19
- "import-sort": "import-sort --write '**/*.{js,jsx,ts,tsx}'",
20
- "format": "pnpm run import-sort && pnpm run prettier"
21
- },
22
11
  "author": {
23
12
  "name": "hanse",
24
13
  "email": "hanse@koren.im"
@@ -47,6 +36,7 @@
47
36
  "eslint-plugin-prettier": "^5.0.0",
48
37
  "eslint-plugin-react": "^7.33.1",
49
38
  "eslint-plugin-react-hooks": "^4.6.0",
39
+ "faktory-worker": "^4.5.1",
50
40
  "import-sort-cli": "6.0.0",
51
41
  "import-sort-style-module": "6.0.0",
52
42
  "isomorphic-fetch": "3.0.0",
@@ -65,6 +55,7 @@
65
55
  "@types/cookie-parser": "^1.4.3",
66
56
  "@types/express": "^4.17.17",
67
57
  "@types/jsonwebtoken": "^9.0.2",
58
+ "@types/koa-compose": "^3.2.5",
68
59
  "@types/nodemailer": "^6.4.9",
69
60
  "@types/pg": "^8.10.2",
70
61
  "@types/uuid": "^9.0.2",
@@ -100,5 +91,19 @@
100
91
  "@typescript-eslint/no-shadow": "off",
101
92
  "react-hooks/rules-of-hooks": "off"
102
93
  }
94
+ },
95
+ "peerDependencies": {
96
+ "faktory-worker": "^4.5.1"
97
+ },
98
+ "scripts": {
99
+ "start": "ts-node --esm example/index.ts",
100
+ "start:worker": "ts-node --esm example/worker.ts",
101
+ "clean": "rm -rf dist",
102
+ "lint": "eslint src",
103
+ "test": "NODE_ENV=test vitest",
104
+ "build": "pnpm run clean && tsc --project tsconfig.build.json",
105
+ "prettier": "prettier --write '**/*.{js,css,md,ts,tsx}'",
106
+ "import-sort": "import-sort --write '**/*.{js,jsx,ts,tsx}'",
107
+ "format": "pnpm run import-sort && pnpm run prettier"
103
108
  }
104
- }
109
+ }