@devmoods/express-extras 0.36.0 → 0.38.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/dist/cache.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import { type RedisClient } from './redis.js';
2
+ export interface CacheOptions {
3
+ key?: string;
4
+ expiresMs?: number | null;
5
+ }
2
6
  export interface CacheAdapter {
3
7
  get(key: string): Promise<any>;
4
8
  set(key: string, value: any, expires?: number | null | undefined): Promise<void>;
@@ -13,5 +17,6 @@ export declare function createCache(adapter: CacheAdapter): {
13
17
  set: (key: string, value: any, expires?: number | null | undefined) => Promise<void>;
14
18
  get: (key: string) => Promise<any>;
15
19
  clear: (key?: string) => Promise<void>;
16
- wrap: <T>(key: string, expiresMs: number | null | undefined, fn: () => Promise<T>) => Promise<T>;
20
+ wrap: <T>(key: string, options: CacheOptions | number | null, fn: () => Promise<T>) => Promise<T>;
21
+ decorate: <T, Arguments extends any[]>(options: CacheOptions | number | null, fn: (...args: Arguments) => T | Promise<T>) => (...args: Arguments) => Promise<T>;
17
22
  };
package/dist/cache.js CHANGED
@@ -53,19 +53,42 @@ export function createMemoryCacheAdapter() {
53
53
  };
54
54
  }
55
55
  export function createCache(adapter) {
56
- async function cached(key, expiresMs, fn) {
56
+ async function cached(key, options, fn) {
57
57
  const cachedValue = await adapter.get(key);
58
58
  if (cachedValue != null) {
59
59
  return cachedValue;
60
60
  }
61
+ const expiresMs = typeof options === 'number' || options == null
62
+ ? options
63
+ : options.expiresMs;
61
64
  const result = await fn();
62
65
  await adapter.set(key, result, expiresMs);
63
66
  return result;
64
67
  }
68
+ function cachedFn(options, fn) {
69
+ return async (...args) => {
70
+ const baseKey = typeof options === 'object' && options != null
71
+ ? options.key
72
+ : undefined;
73
+ const cacheKey = `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${JSON.stringify(args)}`;
74
+ const cachedValue = await adapter.get(cacheKey);
75
+ if (cachedValue != null) {
76
+ return cachedValue;
77
+ }
78
+ const expiresMs = typeof options === 'number' || options == null
79
+ ? options
80
+ : options.expiresMs;
81
+ console.log({ expiresMs });
82
+ const result = await fn(...args);
83
+ await adapter.set(cacheKey, result, expiresMs);
84
+ return result;
85
+ };
86
+ }
65
87
  return {
66
88
  set: adapter.set.bind(adapter),
67
89
  get: adapter.get.bind(adapter),
68
90
  clear: adapter.clear.bind(adapter),
69
91
  wrap: cached,
92
+ decorate: cachedFn,
70
93
  };
71
94
  }
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ export class CLI {
16
16
  redLine(message, output = stderr) {
17
17
  output.write(`${chalk.red(message)}\n`);
18
18
  }
19
- /* v8 ignore next 10 */
19
+ /* c8 ignore next 10 */
20
20
  readInput(question) {
21
21
  const rl = readline.createInterface({ input: stdin, output: stderr });
22
22
  return new Promise((resolve) => {
@@ -31,7 +31,7 @@ export class CLI {
31
31
  const timer = startTimer();
32
32
  try {
33
33
  await action(...args);
34
- this.greenLine(`Command succeeded in ${timer.getDuration().toFixed(4)}s`);
34
+ this.greenLine(`Command succeeded in ${timer.getDuration().toFixed(4)}s`, stderr);
35
35
  }
36
36
  catch (error) {
37
37
  this.redLine(error.message);
package/dist/csrf.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { type RequestHandler, type Request, type Response } from 'express';
2
+ export interface CsrfMiddlewareOptions {
3
+ excempt?: boolean;
4
+ }
5
+ export declare function csrf({ excempt, }?: CsrfMiddlewareOptions): RequestHandler;
6
+ export declare function setCsrfIdentifier(identifier: string): void;
7
+ export declare function useCsrfIdentifier(): string;
8
+ export declare function setCsrfProtectionEnabled(enabled: boolean): void;
9
+ export declare function useCsrfProtectionEnabled(): boolean | undefined;
10
+ interface TokenOptions {
11
+ overwrite?: boolean;
12
+ }
13
+ export declare const generateCsrfToken: (req: Request<any, any, any, any>, res: Response, options?: TokenOptions) => string;
14
+ export declare function doubleCsrfProtection(req: Request<any, any, any, any>): () => Promise<void>;
15
+ export {};
package/dist/csrf.js ADDED
@@ -0,0 +1,104 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import {} from 'express';
3
+ import { setContext, useContext } from './context.js';
4
+ import { Forbidden } from './errors.js';
5
+ import { readEnv } from './utils.js';
6
+ export function csrf({ excempt = false, } = {}) {
7
+ return (req, res, next) => {
8
+ setCsrfProtectionEnabled(!excempt);
9
+ next();
10
+ };
11
+ }
12
+ const CSRF_IDENTIFIER_CONTEXT_KEY = '@csrfRequestIdentifier';
13
+ export function setCsrfIdentifier(identifier) {
14
+ setContext(CSRF_IDENTIFIER_CONTEXT_KEY, identifier);
15
+ }
16
+ export function useCsrfIdentifier() {
17
+ return useContext(CSRF_IDENTIFIER_CONTEXT_KEY) ?? '';
18
+ }
19
+ const CSRF_REQUIRED = '@csrfProtectionEnabled';
20
+ export function setCsrfProtectionEnabled(enabled) {
21
+ setContext(CSRF_REQUIRED, enabled);
22
+ }
23
+ export function useCsrfProtectionEnabled() {
24
+ return useContext(CSRF_REQUIRED);
25
+ }
26
+ const ignoredMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
27
+ const cookieName = '__Host-devmoods.x-csrf-token';
28
+ const generateTokenAndHash = (req, { overwrite = false } = {}) => {
29
+ const secret = readEnv('CSRF_SECRET', '');
30
+ const sessionIdentifier = useCsrfIdentifier();
31
+ const csrfCookie = req.cookies[cookieName];
32
+ if (typeof csrfCookie === 'string' && !overwrite) {
33
+ const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
34
+ if (validateTokenHash({
35
+ csrfToken,
36
+ csrfTokenHash,
37
+ secret,
38
+ sessionIdentifier,
39
+ })) {
40
+ return { csrfToken, csrfTokenHash };
41
+ }
42
+ throw Forbidden('Invalid CSRF token');
43
+ }
44
+ const csrfToken = randomBytes(64).toString('hex');
45
+ const csrfTokenHash = createHash('sha256')
46
+ .update(`${sessionIdentifier}${csrfToken}${secret}`)
47
+ .digest('hex');
48
+ return { csrfToken, csrfTokenHash };
49
+ };
50
+ export const generateCsrfToken = (req, res, options = {}) => {
51
+ const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
52
+ const cookieContent = `${csrfToken}|${csrfTokenHash}`;
53
+ res.cookie(cookieName, cookieContent, {
54
+ sameSite: 'lax',
55
+ path: '/',
56
+ secure: process.env.NODE_ENV === 'production',
57
+ httpOnly: true,
58
+ });
59
+ return csrfToken;
60
+ };
61
+ const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier, }) => {
62
+ if (typeof csrfToken !== 'string' || typeof csrfTokenHash !== 'string') {
63
+ return false;
64
+ }
65
+ const expectedHash = createHash('sha256')
66
+ .update(`${sessionIdentifier}${csrfToken}${secret}`)
67
+ .digest('hex');
68
+ return csrfTokenHash === expectedHash;
69
+ };
70
+ const validateRequest = (req) => {
71
+ const csrfCookie = req.cookies[cookieName];
72
+ if (typeof csrfCookie !== 'string') {
73
+ throw Forbidden('Missing CSRF cookie');
74
+ }
75
+ const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
76
+ const csrfTokenFromRequest = req.headers['x-csrf-token'];
77
+ const secret = readEnv('CSRF_SECRET', '');
78
+ if (!csrfTokenFromRequest) {
79
+ throw Forbidden('Missing CSRF token');
80
+ }
81
+ if (csrfToken !== csrfTokenFromRequest) {
82
+ throw Forbidden('CSRF token mismatch');
83
+ }
84
+ if (!validateTokenHash({
85
+ csrfToken: csrfTokenFromRequest,
86
+ csrfTokenHash,
87
+ secret,
88
+ sessionIdentifier: useCsrfIdentifier(),
89
+ })) {
90
+ throw Forbidden('Invalid CSRF token');
91
+ }
92
+ };
93
+ export function doubleCsrfProtection(req) {
94
+ return async () => {
95
+ const isCsrfProtectionEnabled = useCsrfProtectionEnabled();
96
+ if (!isCsrfProtectionEnabled) {
97
+ return;
98
+ }
99
+ if (ignoredMethods.has(req.method)) {
100
+ return;
101
+ }
102
+ validateRequest(req);
103
+ };
104
+ }
package/dist/email.d.ts CHANGED
@@ -6,12 +6,12 @@ export interface EmailOptions {
6
6
  pass?: string;
7
7
  senderName: string;
8
8
  senderAddress: string;
9
+ onEmailSent?: (payload: Record<string, any>) => void | Promise<void>;
9
10
  }
10
11
  export interface SendEmailOptions extends nodemailer.SendMailOptions {
11
12
  message: string;
12
13
  }
13
- export declare function createEmail({ host, port, user, pass, senderName, senderAddress, }: EmailOptions): {
14
+ export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }: EmailOptions): {
14
15
  send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
15
16
  transporter: nodemailer.Transporter<import("nodemailer/lib/smtp-transport/index.js").SentMessageInfo, import("nodemailer/lib/smtp-transport/index.js").Options>;
16
- readonly outbox: Record<string, any>[];
17
17
  };
package/dist/email.js CHANGED
@@ -1,14 +1,13 @@
1
1
  import * as nodemailer from 'nodemailer';
2
2
  import { getLogger } from './logger.js';
3
3
  const logger = getLogger('email');
4
- export function createEmail({ host, port, user, pass, senderName, senderAddress, }) {
4
+ export function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }) {
5
5
  const transporter = nodemailer.createTransport({
6
6
  host,
7
7
  port: parseInt(port || '587', 10),
8
8
  secure: false,
9
9
  auth: { user, pass },
10
10
  });
11
- const outbox = [];
12
11
  const send = async ({ to, subject, message, ...rest }) => {
13
12
  const payload = {
14
13
  from: `${senderName} <${senderAddress}>`,
@@ -18,17 +17,11 @@ export function createEmail({ host, port, user, pass, senderName, senderAddress,
18
17
  ...rest,
19
18
  };
20
19
  const result = await transporter.sendMail(payload);
21
- outbox.push(payload);
22
- if (outbox.length > 5) {
23
- outbox.shift();
24
- }
20
+ await onEmailSent?.(payload);
25
21
  logger.info('email-sent', { email: result });
26
22
  };
27
23
  return {
28
24
  send,
29
25
  transporter,
30
- get outbox() {
31
- return outbox;
32
- },
33
26
  };
34
27
  }
package/dist/faktory.d.ts CHANGED
@@ -3,14 +3,20 @@ export interface JobsOptions {
3
3
  queueName: string;
4
4
  faktory?: typeof create;
5
5
  }
6
- export type JobContext = Record<string, any>;
7
- export declare function createJobs<GlobalContext extends JobContext>({ queueName, faktory: createFactory, }: JobsOptions): {
8
- job: <K extends Record<string, any>, T extends (input: K, ctx: GlobalContext) => undefined | Promise<any>>(fn: T) => {
6
+ export type AnyJobContext = Record<string, any>;
7
+ export interface JobOptions {
8
+ retry?: number;
9
+ priority?: number;
10
+ at?: Date | string;
11
+ }
12
+ export declare function createJobs<GlobalContext extends AnyJobContext>({ queueName, faktory: createFactory, }: JobsOptions): {
13
+ job: <Arguments extends Record<string, any>, Fn extends (input: Arguments, ctx: GlobalContext) => undefined | Promise<any>>(optionsOrFn: JobOptions | Fn, fnOrUndefined?: Fn) => {
9
14
  $job: boolean;
10
15
  jobName: string;
16
+ options: JobOptions;
11
17
  readonly executionCount: number;
12
- delay(args?: Parameters<T>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
13
- execute(args: Parameters<T>[0], context: GlobalContext): Promise<any>;
18
+ delay(args?: Parameters<Fn>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
19
+ execute(args: Parameters<Fn>[0], context: GlobalContext): Promise<any>;
14
20
  };
15
21
  queueName: string;
16
22
  };
package/dist/faktory.js CHANGED
@@ -7,7 +7,12 @@ const logger = getLogger('faktory');
7
7
  const JOB_ID = 'jobId';
8
8
  export function createJobs({ queueName, faktory: createFactory = create, }) {
9
9
  const faktory = createFactory();
10
- function job(fn) {
10
+ function job(optionsOrFn, fnOrUndefined) {
11
+ const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined;
12
+ const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
13
+ if (!fn) {
14
+ throw new Error('job() must get a function as the first or second argument');
15
+ }
11
16
  const jobName = fn.name;
12
17
  let executionCount = 0;
13
18
  if (!jobName) {
@@ -16,6 +21,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
16
21
  return {
17
22
  $job: true,
18
23
  jobName,
24
+ options,
19
25
  get executionCount() {
20
26
  return executionCount;
21
27
  },
@@ -26,6 +32,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
26
32
  const j = client.job(jobName, args);
27
33
  j.jid = jid;
28
34
  j.queue = queueName;
35
+ Object.assign(j, options);
29
36
  await j.push();
30
37
  await client.close();
31
38
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './auth.js';
2
2
  export * from './cache.js';
3
3
  export * from './cli.js';
4
4
  export * from './context.js';
5
+ export * from './csrf.js';
5
6
  export * from './email.js';
6
7
  export * from './errors.js';
7
8
  export * from './faktory.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- /* v8 ignore next 100 */
1
+ /* c8 ignore next 100 */
2
2
  export * from './auth.js';
3
3
  export * from './cache.js';
4
4
  export * from './cli.js';
5
5
  export * from './context.js';
6
+ export * from './csrf.js';
6
7
  export * from './email.js';
7
8
  export * from './errors.js';
8
9
  export * from './faktory.js';
package/dist/manage.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { program } from 'commander';
2
2
  import {} from 'express';
3
- import { parseExpressApp } from 'express-route-parser';
4
3
  import { hashPassword } from './auth.js';
5
4
  import { CLI } from './cli.js';
6
5
  import { asCli } from './migrate.js';
6
+ import { parseRoutes } from './routes.js';
7
7
  export function registerManagementCommand(register) {
8
8
  const cli = new CLI({ printStackTraces: process.argv.includes('--verbose') });
9
9
  register(program.command.bind(program), cli);
@@ -17,12 +17,23 @@ export function registerDefaultCommands(app) {
17
17
  const migrate = command('migrate');
18
18
  asCli(migrate, cli);
19
19
  });
20
- registerManagementCommand((command, cli) => command('routes').action(cli.wrap(async () => {
20
+ registerManagementCommand((command, cli) => command('routes')
21
+ .option('--openapi', 'Output OpenAPI schema')
22
+ .option('--json', 'Output JSON')
23
+ .action(cli.wrap(async (options) => {
21
24
  if (!app)
22
25
  return;
23
- const routes = parseExpressApp(app);
26
+ if (options.openapi) {
27
+ console.error('Not implemented yet');
28
+ return;
29
+ }
30
+ const routes = parseRoutes(app);
31
+ if (options.json) {
32
+ console.log(JSON.stringify(routes, null, 2));
33
+ return;
34
+ }
24
35
  for (const route of routes) {
25
- cli.writeLine(`${route.method.toLocaleUpperCase().padEnd(5)} ${route.path}\n`);
36
+ cli.writeLine(`${route.method.padEnd(7)} ${route.path}\n`);
26
37
  }
27
38
  })));
28
39
  registerManagementCommand((command, cli) => {
@@ -15,13 +15,14 @@ export interface BeforeMiddlewareOptions {
15
15
  cookies?: boolean;
16
16
  helmet?: boolean;
17
17
  json?: boolean;
18
+ csrf?: boolean;
18
19
  allowUnsafeInlineScripts?: boolean;
19
20
  cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
20
21
  }
21
22
  /**
22
23
  * Register middleware that must come before the app routes.
23
24
  */
24
- export declare function before({ cookies, helmet: useHelmet, json: useJson, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
25
+ export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
25
26
  /**
26
27
  * Middleware that sends a 404
27
28
  *
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
4
4
  import {} from 'express';
5
5
  import helmet from 'helmet';
6
6
  import { withRequestContext } from './context.js';
7
+ import { csrf, doubleCsrfProtection } from './csrf.js';
7
8
  import { NotFound } from './errors.js';
8
9
  import { getLogger } from './logger.js';
9
10
  import { startTimer } from './utils.js';
@@ -14,9 +15,10 @@ export { asyncMiddleware as route };
14
15
  /**
15
16
  * Register middleware that must come before the app routes.
16
17
  */
17
- export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
18
+ export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
18
19
  return compose([
19
20
  withRequestContext(),
21
+ useCsrf && csrf(),
20
22
  logRequestDuration(),
21
23
  useHelmet &&
22
24
  helmet({
@@ -48,7 +50,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
48
50
  },
49
51
  }),
50
52
  useJson && jsonParser(),
51
- cookies && cookieParser(),
53
+ (cookies || useCsrf) && cookieParser(),
52
54
  analyticsProxy(),
53
55
  ].filter(Boolean));
54
56
  }
@@ -111,7 +113,9 @@ export function after() {
111
113
  */
112
114
  export function asyncMiddleware(fn) {
113
115
  const handler = (req, res, next) => {
114
- return Promise.resolve(fn(req, res, next))
116
+ return Promise.resolve()
117
+ .then(doubleCsrfProtection(req))
118
+ .then(() => fn(req, res, next))
115
119
  .then((result) => {
116
120
  if (Array.isArray(result) &&
117
121
  (result.length === 2 || result.length === 3) &&
package/dist/routes.d.ts CHANGED
@@ -1,2 +1,22 @@
1
1
  import { type Express, type RequestHandler } from 'express';
2
2
  export declare function createDocsRouter(app: Express, extras?: Record<string, any>): RequestHandler;
3
+ export declare function parseRoutes(app: Express): {
4
+ method: string;
5
+ path: string;
6
+ params: {
7
+ type: string;
8
+ properties: {
9
+ [k: string]: {
10
+ type: string;
11
+ };
12
+ };
13
+ required: string[];
14
+ } | null;
15
+ query: {} | null;
16
+ body: {} | null;
17
+ }[];
18
+ export interface Key {
19
+ name: string;
20
+ optional: boolean;
21
+ offset: number;
22
+ }
package/dist/routes.js CHANGED
@@ -1,11 +1,137 @@
1
1
  import {} from 'express';
2
- import { parseExpressApp } from 'express-route-parser';
3
2
  import { route } from './middleware.js';
4
3
  export function createDocsRouter(app, extras = {}) {
5
4
  return route(async () => {
6
5
  return {
7
6
  ...extras,
8
- routes: parseExpressApp(app),
7
+ routes: parseRoutes(app),
9
8
  };
10
9
  });
11
10
  }
11
+ export function parseRoutes(app) {
12
+ return new ExpressPathParser(app).getPaths().map((routeMetadata) => {
13
+ const { method, path, pathParams, metadata } = routeMetadata;
14
+ return {
15
+ method: method.toLocaleUpperCase(),
16
+ path,
17
+ params: buildPathParamsSchema(pathParams),
18
+ query: metadata
19
+ ?.filter((m) => typeof m === 'object' && m != null && 'querySchema' in m)
20
+ .at(-1)?.querySchema ?? null,
21
+ body: metadata
22
+ ?.filter((m) => typeof m === 'object' && m != null && 'bodySchema' in m)
23
+ .at(-1)?.bodySchema ?? null,
24
+ };
25
+ });
26
+ }
27
+ const buildPathParamsSchema = (pathParams) => {
28
+ const required = [];
29
+ const params = [];
30
+ for (const param of pathParams) {
31
+ if (param.in !== 'path') {
32
+ continue;
33
+ }
34
+ params.push(param.name);
35
+ if (param.required) {
36
+ required.push(param.name);
37
+ }
38
+ }
39
+ if (params.length === 0) {
40
+ return null;
41
+ }
42
+ return {
43
+ type: 'object',
44
+ properties: Object.fromEntries(params.map((p) => [p, { type: 'string' }])),
45
+ required,
46
+ };
47
+ };
48
+ // Fork of express-route-parser
49
+ class ExpressPathParser {
50
+ getPaths() {
51
+ return this._appPaths;
52
+ }
53
+ constructor(app) {
54
+ this.traverse = (path, layer, keys) => {
55
+ keys = [...keys, ...layer.keys];
56
+ if (layer.name === 'router' && layer.handle) {
57
+ for (const l of layer.handle.stack) {
58
+ this.traverse(`${path || ''}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
59
+ }
60
+ }
61
+ if (!layer.route || !layer.route.stack.length) {
62
+ return;
63
+ }
64
+ this._appPaths.push(this.parseRouteLayer(layer, keys, path));
65
+ };
66
+ this.parseRouteLayer = (layer, keys, basePath) => {
67
+ const lastRequestHandler = layer.route.stack[layer.route.stack.length - 1];
68
+ const pathParams = keys.map((key) => {
69
+ return { name: key.name, in: 'path', required: !key.optional };
70
+ });
71
+ const filtered = layer.route.stack.filter((element) => element?.handle?.metadata);
72
+ const path = (basePath + layer.route.path).replace(/\/{2,}/gi, '/');
73
+ if (filtered.length === 0) {
74
+ return { path, pathParams, method: lastRequestHandler.method };
75
+ }
76
+ return {
77
+ path,
78
+ pathParams,
79
+ method: lastRequestHandler.method,
80
+ metadata: filtered.map((f) => f.handle.metadata),
81
+ };
82
+ };
83
+ this._appPaths = [];
84
+ const router = app._router;
85
+ if (router) {
86
+ for (const layer of router.stack) {
87
+ this.traverse('', layer, []);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ const pathRegexParser = (layerRegexPath, keys) => {
93
+ if (typeof layerRegexPath === 'string') {
94
+ return layerRegexPath;
95
+ }
96
+ if (layerRegexPath.fast_slash) {
97
+ return '';
98
+ }
99
+ if (layerRegexPath.fast_star) {
100
+ return '*';
101
+ }
102
+ let mappedPath = '';
103
+ if (keys && keys.length) {
104
+ mappedPath = mapKeysToPath(layerRegexPath, keys);
105
+ }
106
+ const match = layerRegexPath
107
+ .toString()
108
+ .replace('\\/?', '')
109
+ .replace('(?=\\/|$)', '$')
110
+ .match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//);
111
+ if (match) {
112
+ return match[1].replace(/\\(.)/g, '$1').slice(1);
113
+ }
114
+ if (mappedPath) {
115
+ return mappedPath.slice(1);
116
+ }
117
+ return layerRegexPath.toString();
118
+ };
119
+ const mapKeysToPath = (layerRegexPath, keys) => {
120
+ if (!keys || keys.length === 0) {
121
+ throw Error('must include atleast one key to map');
122
+ }
123
+ let convertedSubPath = layerRegexPath.toString();
124
+ for (const key of keys) {
125
+ if (key.optional) {
126
+ convertedSubPath = convertedSubPath.replace('(?:\\/([^\\/]+?))?\\', `/:${key.name}?`);
127
+ }
128
+ else {
129
+ convertedSubPath = convertedSubPath.replace('(?:([^\\/]+?))', `:${key.name}`);
130
+ }
131
+ }
132
+ return convertedSubPath
133
+ .replace('/?(?=\\/|$)/i', '')
134
+ .replace('/^', '')
135
+ .replace(/\\/gi, '')
136
+ .replace(/\/{2,}/gi, '/');
137
+ };
@@ -1,3 +1,5 @@
1
1
  import 'dotenv/config';
2
+ import EventEmitter from 'node:events';
2
3
  import failOnConsole from 'vitest-fail-on-console';
4
+ EventEmitter.setMaxListeners(20);
3
5
  failOnConsole();
package/dist/validate.js CHANGED
@@ -4,33 +4,39 @@ import { BadRequest } from './errors.js';
4
4
  const Ajv = _Ajv;
5
5
  export function validateBody(schema) {
6
6
  const validate = compileValidateSchema(schema);
7
- return (req, res, next) => {
7
+ const middleware = (req, res, next) => {
8
8
  const result = validate(req.body);
9
9
  if (!result.isValid) {
10
10
  return next(BadRequest({ body: result.errors }));
11
11
  }
12
12
  next();
13
13
  };
14
+ middleware.metadata = { bodySchema: schema };
15
+ return middleware;
14
16
  }
15
17
  export function validateQuery(schema) {
16
18
  const validate = compileValidateSchema(schema, { coerceTypes: true });
17
- return (req, res, next) => {
19
+ const middleware = (req, res, next) => {
18
20
  const result = validate(req.query);
19
21
  if (!result.isValid) {
20
22
  return next(BadRequest({ query: result.errors }));
21
23
  }
22
24
  next();
23
25
  };
26
+ middleware.metadata = { querySchema: schema };
27
+ return middleware;
24
28
  }
25
29
  export function validateParams(schema) {
26
30
  const validate = compileValidateSchema(schema, { coerceTypes: true });
27
- return (req, res, next) => {
31
+ const middleware = (req, res, next) => {
28
32
  const result = validate(req.params);
29
33
  if (!result.isValid) {
30
34
  return next(BadRequest({ params: result.errors }));
31
35
  }
32
36
  next();
33
37
  };
38
+ middleware.metadata = { paramsSchema: schema };
39
+ return middleware;
34
40
  }
35
41
  function createAjv(options) {
36
42
  const ajv = new Ajv({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,12 +20,12 @@
20
20
  "devDependencies": {
21
21
  "@devmoods/eslint-config": "^1.14.0",
22
22
  "@types/koa-compose": "^3.2.8",
23
- "@types/node": "22.7.5",
23
+ "@types/node": "22.8.6",
24
24
  "@types/supertest": "^6.0.2",
25
- "@typescript-eslint/eslint-plugin": "^8.8.1",
26
- "@typescript-eslint/parser": "^8.8.1",
27
- "@vitest/coverage-v8": "^2.1.2",
28
- "chalk": "^5.3.0",
25
+ "@typescript-eslint/eslint-plugin": "^8.12.2",
26
+ "@typescript-eslint/parser": "^8.12.2",
27
+ "@vitest/coverage-v8": "^2.1.4",
28
+ "cookiejar": "^2.1.4",
29
29
  "eslint": "^8.57.0",
30
30
  "eslint-plugin-import": "^2.31.0",
31
31
  "eslint-plugin-prettier": "^5.2.1",
@@ -33,15 +33,15 @@
33
33
  "prettier": "3.3.3",
34
34
  "supertest": "7.0.0",
35
35
  "ts-node": "10.9.2",
36
- "tsx": "^4.19.1",
36
+ "tsx": "^4.19.2",
37
37
  "typescript": "5.6.3",
38
- "vitest": "^2.1.2",
38
+ "vitest": "^2.1.4",
39
39
  "vitest-fail-on-console": "^0.7.1"
40
40
  },
41
41
  "dependencies": {
42
42
  "@devmoods/fetch": "^4.0.1",
43
- "@sentry/node": "^8.34.0",
44
- "@sentry/profiling-node": "^8.34.0",
43
+ "@sentry/node": "^8.36.0",
44
+ "@sentry/profiling-node": "^8.36.0",
45
45
  "@types/bcrypt": "^5.0.2",
46
46
  "@types/body-parser": "^1.19.5",
47
47
  "@types/cookie-parser": "^1.4.7",
@@ -54,19 +54,19 @@
54
54
  "ajv": "^8.17.1",
55
55
  "bcrypt": "^5.1.1",
56
56
  "body-parser": "^1.20.3",
57
+ "chalk": "^5.3.0",
57
58
  "commander": "^12.1.0",
58
59
  "compose-middleware": "^5.0.1",
59
60
  "cookie-parser": "^1.4.7",
60
61
  "dotenv": "^16.4.5",
61
62
  "express": "^4.21.1",
62
- "express-route-parser": "^1.0.5",
63
63
  "helmet": "^8.0.0",
64
64
  "jsonwebtoken": "^9.0.2",
65
- "nodemailer": "^6.9.15",
66
- "pg": "^8.13.0",
65
+ "nodemailer": "^6.9.16",
66
+ "pg": "^8.13.1",
67
67
  "redis": "^4.7.0",
68
68
  "sql-template-strings": "^2.2.2",
69
- "uuid": "^10.0.0"
69
+ "uuid": "^11.0.2"
70
70
  },
71
71
  "prettier": {
72
72
  "singleQuote": true