@devmoods/express-extras 0.73.0 → 0.75.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.js CHANGED
@@ -1,62 +1,116 @@
1
1
  import { config } from '@devmoods/config';
2
2
  import {} from './redis.js';
3
- export function createRedisCacheAdapter(redis, options = {}) {
4
- const { keyPrefix = `${config.REDIS_KEY_PREFIX}cache:` } = options;
5
- const withKeyPrefix = (key) => `${keyPrefix}${key}`;
6
- return {
7
- async get(key) {
8
- const value = await redis.get(withKeyPrefix(key));
9
- if (value == null) {
10
- return null;
11
- }
12
- return JSON.parse(value);
13
- },
14
- async set(key, value, expires) {
15
- await redis.set(withKeyPrefix(key), JSON.stringify(value), {
16
- PX: expires,
3
+ const MAX_CACHE_KEY_SIZE_BYTES = 256;
4
+ export class CacheAdapter {
5
+ }
6
+ export class RedisCacheAdapter extends CacheAdapter {
7
+ redis;
8
+ keyPrefix;
9
+ constructor(redis, { keyPrefix = `${config.REDIS_KEY_PREFIX}cache:`, } = {}) {
10
+ super();
11
+ this.redis = redis;
12
+ this.keyPrefix = keyPrefix;
13
+ }
14
+ async get(key) {
15
+ const value = await this.redis.get(this.withKeyPrefix(key));
16
+ if (value == null) {
17
+ return null;
18
+ }
19
+ return JSON.parse(value);
20
+ }
21
+ async set(key, value, expires) {
22
+ const serializedValue = JSON.stringify(value);
23
+ if (expires == null) {
24
+ await this.redis.set(this.withKeyPrefix(key), serializedValue);
25
+ return;
26
+ }
27
+ await this.redis.set(this.withKeyPrefix(key), serializedValue, {
28
+ PX: expires,
29
+ });
30
+ }
31
+ async clear(key) {
32
+ if (key) {
33
+ await this.redis.del(this.withKeyPrefix(key));
34
+ return;
35
+ }
36
+ let cursor = '0';
37
+ const pattern = `${this.keyPrefix}*`;
38
+ do {
39
+ const result = await this.redis.scan(cursor, {
40
+ MATCH: pattern,
41
+ COUNT: 100,
17
42
  });
18
- },
19
- async clear(key) {
20
- if (key) {
21
- await redis.del(withKeyPrefix(key));
22
- }
23
- const keys = await redis.keys(`${keyPrefix}*`);
24
- for (const existingKey of keys) {
25
- await redis.del(existingKey);
43
+ cursor = result.cursor;
44
+ /* v8 ignore next -- @preserve */
45
+ if (result.keys.length > 0) {
46
+ await this.redis.unlink(result.keys);
26
47
  }
27
- },
28
- };
48
+ } while (cursor !== '0');
49
+ }
50
+ withKeyPrefix(key) {
51
+ return `${this.keyPrefix}${key}`;
52
+ }
29
53
  }
30
- export function createMemoryCacheAdapter() {
31
- const cache = new Map();
32
- return {
33
- async get(key) {
34
- const entry = cache.get(key);
35
- if (entry == null) {
36
- return null;
37
- }
38
- const [value, expires] = entry;
39
- if (expires != null && Date.now() >= expires) {
40
- cache.delete(key);
41
- return null;
42
- }
43
- return value;
44
- },
45
- async set(key, value, expires) {
46
- cache.set(key, [value, expires == null ? null : Date.now() + expires]);
47
- },
48
- async clear(key) {
49
- if (key) {
50
- cache.delete(key);
51
- }
52
- else {
53
- cache.clear();
54
+ export function createRedisCacheAdapter(redis, options = {}) {
55
+ return new RedisCacheAdapter(redis, options);
56
+ }
57
+ export class MemoryCacheAdapter extends CacheAdapter {
58
+ maxSize;
59
+ #cache = new Map();
60
+ constructor({ maxSize } = {}) {
61
+ super();
62
+ this.maxSize = maxSize;
63
+ }
64
+ async get(key) {
65
+ const entry = this.#cache.get(key);
66
+ if (entry == null) {
67
+ return null;
68
+ }
69
+ const [value, expires] = entry;
70
+ if (expires != null && Date.now() >= expires) {
71
+ this.#cache.delete(key);
72
+ return null;
73
+ }
74
+ // Mark as recently used by deleting the entry and adding it back to the cache
75
+ this.#cache.delete(key);
76
+ this.#cache.set(key, entry);
77
+ return value;
78
+ }
79
+ async set(key, value, expires) {
80
+ this.#cache.delete(key);
81
+ this.#cache.set(key, [
82
+ value,
83
+ expires == null ? null : Date.now() + expires,
84
+ ]);
85
+ this.evictLeastRecentlyUsedEntries();
86
+ }
87
+ async clear(key) {
88
+ if (key) {
89
+ this.#cache.delete(key);
90
+ }
91
+ else {
92
+ this.#cache.clear();
93
+ }
94
+ }
95
+ evictLeastRecentlyUsedEntries() {
96
+ if (this.maxSize == null) {
97
+ return;
98
+ }
99
+ while (this.#cache.size > this.maxSize) {
100
+ const oldestKey = this.#cache.keys().next().value;
101
+ if (oldestKey == null) {
102
+ return;
54
103
  }
55
- },
56
- };
104
+ this.#cache.delete(oldestKey);
105
+ }
106
+ }
107
+ }
108
+ export function createMemoryCacheAdapter(options = {}) {
109
+ return new MemoryCacheAdapter(options);
57
110
  }
58
111
  export function createCache(adapter) {
59
112
  async function cached(key, options, fn) {
113
+ assertCacheKeySize(key);
60
114
  const cachedValue = await adapter.get(key);
61
115
  if (cachedValue != null) {
62
116
  return cachedValue;
@@ -73,7 +127,9 @@ export function createCache(adapter) {
73
127
  const baseKey = typeof options === 'object' && options != null
74
128
  ? options.key
75
129
  : undefined;
76
- return `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${hashKey(args)}`;
130
+ const cacheKey = `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${hashKey(args)}`;
131
+ assertCacheKeySize(cacheKey);
132
+ return cacheKey;
77
133
  };
78
134
  const wrapped = async (...args) => {
79
135
  const cacheKey = getCacheKey(...args);
@@ -101,21 +157,28 @@ export function createCache(adapter) {
101
157
  };
102
158
  }
103
159
  function hashKey(obj) {
104
- return JSON.stringify(obj, (key, value) => {
105
- if (value === null || value === undefined) {
106
- return String(value);
107
- }
108
- if (typeof value === 'object') {
109
- if (Array.isArray(value)) {
110
- return value;
111
- }
112
- return Object.keys(value)
113
- .sort()
114
- .reduce((result, item) => {
115
- result[item] = value[item];
116
- return result;
117
- }, {});
160
+ return JSON.stringify(obj, (_, value) => {
161
+ if (!isPlainObject(value)) {
162
+ return value;
118
163
  }
119
- return value;
164
+ return Object.keys(value)
165
+ .sort()
166
+ .reduce((result, key) => {
167
+ result[key] = value[key];
168
+ return result;
169
+ }, {});
120
170
  });
121
171
  }
172
+ function assertCacheKeySize(key) {
173
+ if (Buffer.byteLength(key, 'utf8') <= MAX_CACHE_KEY_SIZE_BYTES) {
174
+ return;
175
+ }
176
+ throw new Error(`Cache key exceeds ${MAX_CACHE_KEY_SIZE_BYTES} bytes. Provide a shorter explicit key or reduce the size of the cache arguments.`);
177
+ }
178
+ function isPlainObject(value) {
179
+ if (Object.prototype.toString.call(value) !== '[object Object]') {
180
+ return false;
181
+ }
182
+ const prototype = Object.getPrototypeOf(value);
183
+ return prototype === null || prototype === Object.prototype;
184
+ }
@@ -20,14 +20,14 @@ function generate(handlers) {
20
20
  const stack = handlers.flat();
21
21
  return function middleware(err, req, res, done) {
22
22
  let index = -1;
23
- // eslint-disable-next-line @typescript-eslint/no-shadow
23
+ // oxlint-disable-next-line @typescript-eslint/no-shadow
24
24
  function dispatch(pos, err) {
25
25
  const handler = stack[pos];
26
26
  index = pos;
27
27
  if (index === stack.length) {
28
28
  return done(err);
29
29
  }
30
- // eslint-disable-next-line @typescript-eslint/no-shadow
30
+ // oxlint-disable-next-line @typescript-eslint/no-shadow
31
31
  function next(err) {
32
32
  /* v8 ignore next -- @preserve */
33
33
  if (pos < index) {
package/dist/config.d.ts CHANGED
@@ -2,13 +2,14 @@ declare module '@devmoods/config' {
2
2
  interface Config extends ConfigFromDefs<typeof app> {
3
3
  }
4
4
  }
5
- declare const app: Defs<{
5
+ declare const app: import("@devmoods/config").Defs<{
6
6
  CI: boolean;
7
7
  PORT: number;
8
8
  PUBLIC_URL: string;
9
9
  AUTH_TOKEN_EXPIRES: number;
10
10
  AUTH_TEMP_TOKEN_EXPIRES: number;
11
11
  AUTH_OTP_EXPIRES: number;
12
+ AUTH_OTP_MAX_ATTEMPTS: number;
12
13
  AUTH_JWT_SECRET: string;
13
14
  AUTH_COOKIE_SAME_SITE: string;
14
15
  REDIS_URL: string;
@@ -41,6 +42,7 @@ export declare const config: import("@devmoods/config").Defs<{
41
42
  AUTH_TOKEN_EXPIRES: number;
42
43
  AUTH_TEMP_TOKEN_EXPIRES: number;
43
44
  AUTH_OTP_EXPIRES: number;
45
+ AUTH_OTP_MAX_ATTEMPTS: number;
44
46
  AUTH_JWT_SECRET: string;
45
47
  AUTH_COOKIE_SAME_SITE: string;
46
48
  REDIS_URL: string;
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ const app = define({
7
7
  AUTH_TOKEN_EXPIRES: t.number(3600 * 24 * 30),
8
8
  AUTH_TEMP_TOKEN_EXPIRES: t.number(3600),
9
9
  AUTH_OTP_EXPIRES: t.number(5 * 60),
10
+ AUTH_OTP_MAX_ATTEMPTS: t.number(5),
10
11
  AUTH_JWT_SECRET: t.string(),
11
12
  AUTH_COOKIE_SAME_SITE: t.enum(['strict', 'lax', 'none'], 'strict'),
12
13
  REDIS_URL: t.string(),
package/dist/context.d.ts CHANGED
@@ -9,7 +9,7 @@ export interface RequestContextOptions {
9
9
  /**
10
10
  * Middleware for setting a `requestId` on the request context.
11
11
  */
12
- export declare function withRequestContext({ getRequestId }?: RequestContextOptions): RequestHandler;
12
+ export declare function withRequestContext({ getRequestId, }?: RequestContextOptions): RequestHandler;
13
13
  /**
14
14
  * Return a value from the request context
15
15
  */
package/dist/context.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import {} from 'express';
3
- import { v4 as uuid } from 'uuid';
4
4
  export const requestContext = new AsyncLocalStorage();
5
5
  export const REQUEST_ID_CONTEXT_KEY = 'requestId';
6
6
  export const JOB_ID_CONTEXT_KEY = 'jobId';
@@ -9,7 +9,7 @@ export const JOB_ID_CONTEXT_KEY = 'jobId';
9
9
  */
10
10
  export function withRequestContext({ getRequestId = (req) => req.headers['X-Request-ID'], } = {}) {
11
11
  return (req, res, next) => {
12
- const requestId = getRequestId(req) || uuid();
12
+ const requestId = getRequestId(req) || randomUUID();
13
13
  requestContext.run(new Map(), () => {
14
14
  setRequestContext(REQUEST_ID_CONTEXT_KEY, requestId);
15
15
  next();
@@ -1,3 +1,3 @@
1
- export declare function cookieConsent({ onConsent }: {
1
+ export declare function cookieConsent({ onConsent, }: {
2
2
  onConsent: (consents: string[]) => Promise<void> | void;
3
3
  }): import("express-serve-static-core").Router;
package/dist/csrf.d.ts CHANGED
@@ -10,7 +10,7 @@ interface CsrfOptions {
10
10
  cookieName: string;
11
11
  sessionIdentifier: string;
12
12
  }
13
- export declare function csrf({ exempt, sessionIdentifier, cookieName }?: CsrfMiddlewareOptions): RequestHandler;
13
+ export declare function csrf({ exempt, sessionIdentifier, cookieName, }?: CsrfMiddlewareOptions): RequestHandler;
14
14
  export declare function setCsrfOptions(options: CsrfOptions): void;
15
15
  export declare function useCsrfOptions(): CsrfOptions;
16
16
  interface TokenOptions {
package/dist/csrf.js CHANGED
@@ -43,6 +43,7 @@ export function useCsrfOptions() {
43
43
  }
44
44
  const generateTokenAndHash = (req, { overwrite = false } = {}) => {
45
45
  const secret = getCsrfSecret();
46
+ // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
46
47
  const { sessionIdentifier, cookieName } = useCsrfOptions();
47
48
  const csrfCookie = req.cookies[cookieName];
48
49
  if (typeof csrfCookie === 'string' && !overwrite) {
@@ -66,6 +67,7 @@ const generateTokenAndHash = (req, { overwrite = false } = {}) => {
66
67
  export const generateCsrfToken = (req, res, options = {}) => {
67
68
  const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
68
69
  const cookieContent = `${csrfToken}|${csrfTokenHash}`;
70
+ // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
69
71
  const { cookieName } = useCsrfOptions();
70
72
  res.cookie(cookieName, cookieContent, {
71
73
  sameSite: 'lax',
@@ -85,6 +87,7 @@ const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier
85
87
  return csrfTokenHash === expectedHash;
86
88
  };
87
89
  const validateRequest = (req) => {
90
+ // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
88
91
  const { cookieName, sessionIdentifier } = useCsrfOptions();
89
92
  const csrfCookie = req.cookies[cookieName];
90
93
  if (typeof csrfCookie !== 'string') {
@@ -112,6 +115,7 @@ const validateRequest = (req) => {
112
115
  }
113
116
  };
114
117
  export function doubleCsrfProtection(req) {
118
+ // oxlint-disable-next-line eslint-plugin-react-hooks/rules-of-hooks
115
119
  return async () => {
116
120
  const { exempt } = useCsrfOptions();
117
121
  if (exempt) {
package/dist/email.d.ts CHANGED
@@ -11,7 +11,7 @@ export interface EmailOptions {
11
11
  export interface SendEmailOptions extends nodemailer.SendMailOptions {
12
12
  message: string;
13
13
  }
14
- export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent }?: EmailOptions): {
14
+ export declare function createEmail({ host, port, user, pass, senderName, senderAddress, onEmailSent, }?: EmailOptions): {
15
15
  send: ({ to, subject, message, ...rest }: SendEmailOptions) => Promise<void>;
16
16
  transporter: nodemailer.Transporter<import("nodemailer/lib/smtp-transport/index.js").SentMessageInfo, import("nodemailer/lib/smtp-transport/index.js").Options>;
17
17
  };
package/dist/faktory.d.ts CHANGED
@@ -29,7 +29,7 @@ export interface FaktoryWorkerOptions<GlobalContext> {
29
29
  jobContext?: GlobalContext;
30
30
  }
31
31
  export { createFaktoryWorker as createJobWorker };
32
- export declare function createFaktoryWorker<GlobalContext>({ queues, jobs, faktory: createFactory, jobContext }: FaktoryWorkerOptions<GlobalContext>): Promise<{
32
+ export declare function createFaktoryWorker<GlobalContext>({ queues, jobs, faktory: createFactory, jobContext, }: FaktoryWorkerOptions<GlobalContext>): Promise<{
33
33
  start(workerOptions?: WorkerOptions): Promise<void>;
34
34
  stop(): Promise<void>;
35
35
  }>;
package/dist/listen.js CHANGED
@@ -8,7 +8,6 @@ import express, {} from 'express';
8
8
  import { BadRequest } from './errors.js';
9
9
  import { getLogger } from './logger.js';
10
10
  import { before, route } from './middleware.js';
11
- import { envPort } from './utils.js';
12
11
  const logger = getLogger('http');
13
12
  const appTimer = startTimer();
14
13
  export function stoppable(server, gracePeriodMs = Infinity) {
@@ -175,7 +174,7 @@ export function createExpressServer(app, options = {}) {
175
174
  configurable: true,
176
175
  });
177
176
  enhanced.start = async function start() {
178
- const port = app.get('port') ?? envPort(0);
177
+ const port = config.PORT;
179
178
  await options.onStart?.();
180
179
  await ctl.start(port);
181
180
  logger.info('app listening', {
package/dist/logger.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare function clearGlobalLoggerContext(): void;
30
30
  export type Logger = ReturnType<typeof createLogger>;
31
31
  export declare function addDefaultLoggerOutput(output: LoggerAdapter): void;
32
32
  export declare function clearDefaultLoggerOutputs(): void;
33
- export declare function createLogger({ name, getContext, level, outputs }: LoggerOptions): {
33
+ export declare function createLogger({ name, getContext, level, outputs, }: LoggerOptions): {
34
34
  debug(message: string, context?: LoggerContext): void;
35
35
  info(message: string, context?: LoggerContext): void;
36
36
  warn(message: string, context?: LoggerContext): void;
package/dist/logger.js CHANGED
@@ -7,7 +7,7 @@ export class JsonStreamAdapter {
7
7
  constructor(stream) {
8
8
  this.#stream = stream;
9
9
  }
10
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ // oxlint-disable-next-line @typescript-eslint/no-unused-vars
11
11
  captureError(error, attributes) { }
12
12
  write(log) {
13
13
  const chunk = `${JSON.stringify(log)}\n`;
@@ -72,7 +72,7 @@ export function createLogger({ name, getContext, level = config.LOG_LEVEL, outpu
72
72
  function getFinalContext(context) {
73
73
  return Object.assign(baseContext(), getContext?.(), context);
74
74
  }
75
- // eslint-disable-next-line @typescript-eslint/no-shadow
75
+ // oxlint-disable-next-line @typescript-eslint/no-shadow
76
76
  function log(level, message, context) {
77
77
  if (level < logLevel) {
78
78
  return;
package/dist/manage.js CHANGED
@@ -33,13 +33,13 @@ export function registerDefaultCommands(app, program = defaultProgram) {
33
33
  }
34
34
  if (options.openapi) {
35
35
  const routes = toOpenAPISpec(app);
36
- // eslint-disable-next-line no-console
36
+ // oxlint-disable-next-line no-console
37
37
  console.log(JSON.stringify(routes, null, 2));
38
38
  return;
39
39
  }
40
40
  const routes = parseRoutes(app);
41
41
  if (options.json) {
42
- // eslint-disable-next-line no-console
42
+ // oxlint-disable-next-line no-console
43
43
  console.log(JSON.stringify(routes, null, 2));
44
44
  return;
45
45
  }
@@ -60,7 +60,7 @@ export function registerDefaultCommands(app, program = defaultProgram) {
60
60
  .option('--json', 'Output JSON')
61
61
  .action(cli.wrap(async (options) => {
62
62
  if (options.json) {
63
- // eslint-disable-next-line no-console
63
+ // oxlint-disable-next-line no-console
64
64
  console.log(JSON.stringify(config, null, 2));
65
65
  return;
66
66
  }
@@ -55,7 +55,7 @@ export interface BeforeMiddleware extends RequestHandler {
55
55
  /**
56
56
  * Register middleware that must come before the app routes.
57
57
  */
58
- export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, urlencoded: useUrlencoded, allowUnsafeInlineScripts, cspDirectives, auth }?: BeforeMiddlewareOptions): BeforeMiddleware;
58
+ export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, urlencoded: useUrlencoded, allowUnsafeInlineScripts, cspDirectives, auth, }?: BeforeMiddlewareOptions): BeforeMiddleware;
59
59
  /**
60
60
  * Middleware that sends a 404
61
61
  *
@@ -1,13 +1,13 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import {} from 'node:http';
3
3
  import { config } from '@devmoods/config';
4
- import { startTimer } from '@devmoods/observability';
4
+ import { metrics, startTimer } from '@devmoods/observability';
5
5
  import cookieParser from 'cookie-parser';
6
6
  import { json as jsonParser, urlencoded as urlencodedParser, static as staticMiddleware, } from 'express';
7
7
  import helmet from 'helmet';
8
8
  import {} from './auth.js';
9
9
  import { compose } from './composeMiddleware.js';
10
- import { ANONYMOUS_SESSION_KEY, setRequestContext, useRequestContext, withRequestContext, } from './context.js';
10
+ import { ANONYMOUS_SESSION_KEY, requestContext, setRequestContext, useRequestContext, withRequestContext, } from './context.js';
11
11
  import { csrf, doubleCsrfProtection, } from './csrf.js';
12
12
  import { NotFound } from './errors.js';
13
13
  import { getLogger } from './logger.js';
@@ -26,6 +26,7 @@ export function useCspNonce() {
26
26
  export function before({ cookies = true, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, urlencoded: useUrlencoded = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, auth, } = {}) {
27
27
  const middleware = compose([
28
28
  withRequestContext(),
29
+ startRequestTimer(),
29
30
  (cookies || useCsrf) && cookieParser(),
30
31
  cookies && setAnonymousCookie(),
31
32
  typeof useCsrf === 'object' && useCsrf != null
@@ -76,21 +77,52 @@ export function before({ cookies = true, helmet: useHelmet = true, json: useJson
76
77
  });
77
78
  return middleware;
78
79
  }
80
+ const REQUEST_TIMER_KEY = 'request-timer';
81
+ const LOG_REQUEST_FILTERED_PATHS = [
82
+ '/src',
83
+ '/node_modules',
84
+ '/@fs',
85
+ '/@vite',
86
+ '/@react-refresh',
87
+ '/@id',
88
+ '/analytics.js',
89
+ ];
90
+ function isLogFilteredPath(req) {
91
+ return LOG_REQUEST_FILTERED_PATHS.some((p) => req.path.startsWith(p));
92
+ }
93
+ /**
94
+ * Derive a low-cardinality route label (e.g. `/users/:id`) for metrics.
95
+ * Falls back to `unknown` when no route matched (e.g. 404).
96
+ */
97
+ function getRouteLabel(req) {
98
+ const routePath = req.route?.path;
99
+ if (!routePath) {
100
+ return 'unknown';
101
+ }
102
+ /* c8 ignore next 1 */
103
+ const pattern = Array.isArray(routePath) ? routePath.join('|') : routePath;
104
+ return `${req.baseUrl}${pattern}`;
105
+ }
106
+ /**
107
+ * Start a timer as early as possible in the middleware chain so the
108
+ * duration reported by `logRequestDuration` includes time spent in
109
+ * upstream middlewares (cookie parsing, CSRF, auth, etc.).
110
+ */
111
+ function startRequestTimer() {
112
+ return function startRequestTimerMiddleware(req, res, next) {
113
+ if (isLogFilteredPath(req)) {
114
+ return next();
115
+ }
116
+ setRequestContext(REQUEST_TIMER_KEY, startTimer());
117
+ next();
118
+ };
119
+ }
79
120
  function logRequestDuration() {
80
- const FILTERED_PATHS = [
81
- '/src',
82
- '/node_modules',
83
- '/@fs',
84
- '/@vite',
85
- '/@react-refresh',
86
- '/@id',
87
- '/analytics.js',
88
- ];
89
121
  return function logRequestDurationMiddleware(req, res, next) {
90
- if (FILTERED_PATHS.some((p) => req.path.startsWith(p))) {
122
+ const timer = requestContext.getStore()?.get(REQUEST_TIMER_KEY);
123
+ if (!timer) {
91
124
  return next();
92
125
  }
93
- const timer = startTimer();
94
126
  logger.info('http request', {
95
127
  method: req.method,
96
128
  url: req.originalUrl,
@@ -107,13 +139,20 @@ function logRequestDuration() {
107
139
  });
108
140
  res.on('finish', () => {
109
141
  const duration = timer.getDuration();
142
+ const route = getRouteLabel(req);
110
143
  logger.info('http response', {
111
144
  method: req.method,
112
145
  url: req.originalUrl,
113
146
  path: req.path,
147
+ route,
114
148
  duration,
115
149
  status: res.statusCode,
116
150
  });
151
+ metrics.distribution('http_request_duration_seconds', duration, {
152
+ method: req.method,
153
+ route,
154
+ status: res.statusCode >= 400 ? 'error' : 'success',
155
+ });
117
156
  });
118
157
  next();
119
158
  };
@@ -225,14 +264,41 @@ export function withTransaction(wrapper) {
225
264
  try {
226
265
  await wrapper.transaction(async () => {
227
266
  await new Promise((resolve, reject) => {
228
- res.on('finish', resolve);
229
- res.on('error', reject);
267
+ const cleanup = () => {
268
+ res.off('finish', onFinish);
269
+ res.off('close', onClose);
270
+ res.off('error', onError);
271
+ };
272
+ const onFinish = () => {
273
+ cleanup();
274
+ if (res.statusCode >= 400) {
275
+ reject(new Error(`Request finished with status ${res.statusCode}; rolling back transaction.`));
276
+ return;
277
+ }
278
+ resolve();
279
+ };
280
+ const onClose = () => {
281
+ cleanup();
282
+ if (!res.writableFinished) {
283
+ reject(new Error('Request closed before the response finished.'));
284
+ }
285
+ };
286
+ const onError = (error) => {
287
+ cleanup();
288
+ reject(error);
289
+ };
290
+ res.once('finish', onFinish);
291
+ res.once('close', onClose);
292
+ res.once('error', onError);
230
293
  next();
231
294
  });
232
295
  });
233
296
  }
234
297
  catch (error) {
235
298
  /* v8 ignore next -- @preserve */
299
+ if (res.headersSent || res.writableEnded) {
300
+ return;
301
+ }
236
302
  next(error);
237
303
  }
238
304
  };
@@ -30,4 +30,4 @@ export interface RateLimitOptions {
30
30
  /**
31
31
  * Returns a middleware for rate limiting requests.
32
32
  */
33
- export declare function rateLimit(redis: RedisClient, { max, windowSizeInSeconds, getKey }?: RateLimitOptions): RateLimitMiddleware;
33
+ export declare function rateLimit(redis: RedisClient, { max, windowSizeInSeconds, getKey, }?: RateLimitOptions): RateLimitMiddleware;
package/dist/redis.d.ts CHANGED
@@ -4,4 +4,4 @@ export interface RedisOptions {
4
4
  onError?: (error: Error) => void;
5
5
  }
6
6
  export type RedisClient = ReturnType<typeof createClient>;
7
- export declare function createRedis({ url, onError }?: RedisOptions): RedisClient;
7
+ export declare function createRedis({ url, onError, }?: RedisOptions): RedisClient;
package/dist/routes.d.ts CHANGED
@@ -2,9 +2,9 @@ import { type Application } from 'express';
2
2
  export declare function createDocsRouter(app: Application, extras?: Record<string, any>): import("express").RequestHandler<Record<string, string>, {
3
3
  openapi: string;
4
4
  info: {
5
+ description?: string | undefined;
5
6
  title: string;
6
7
  version: string;
7
- description?: string | undefined;
8
8
  };
9
9
  tags: {
10
10
  name: string;
@@ -42,9 +42,9 @@ export declare function toOpenAPISpec(app: Application, info?: {
42
42
  }): {
43
43
  openapi: string;
44
44
  info: {
45
+ description?: string | undefined;
45
46
  title: string;
46
47
  version: string;
47
- description?: string | undefined;
48
48
  };
49
49
  tags: {
50
50
  name: string;
package/dist/slack.d.ts CHANGED
@@ -17,7 +17,7 @@ type Blocks = unknown[];
17
17
  *
18
18
  * See https://api.slack.com/messaging/webhooks
19
19
  */
20
- export declare function createSlack({ webhookUrl, dryRun }?: SlackOptions): {
20
+ export declare function createSlack({ webhookUrl, dryRun, }?: SlackOptions): {
21
21
  postMessage: (message: Message, blocks?: Blocks) => Promise<unknown>;
22
22
  };
23
23
  export {};
package/dist/utils.d.ts CHANGED
@@ -4,10 +4,7 @@ export declare function readEnv(key: string): string | undefined;
4
4
  export declare function readEnv(key: string, defaultValue: string): string;
5
5
  export declare namespace readEnv {
6
6
  var number: typeof readEnvNumber;
7
- }
8
- export declare namespace readEnv {
9
7
  var boolean: typeof readEnvBoolean;
10
8
  }
11
9
  declare function readEnvNumber(key: string, defaultValue: number): number;
12
10
  declare function readEnvBoolean(key: string, defaultValue: boolean): boolean;
13
- export declare const envPort: (defaultPort?: number) => number;