@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/auth/emailAndPassword.js +14 -8
- package/dist/auth/google-client.d.ts +12 -0
- package/dist/auth/google-client.js +27 -0
- package/dist/auth/google.d.ts +2 -1
- package/dist/auth/google.js +34 -21
- package/dist/auth/passwordless.d.ts +1 -1
- package/dist/auth/passwordless.js +23 -11
- package/dist/auth/token.d.ts +2 -2
- package/dist/auth/token.js +3 -3
- package/dist/auth/tokenStorage.d.ts +1 -1
- package/dist/auth/tokenStorage.js +1 -1
- package/dist/auth/types.d.ts +6 -2
- package/dist/auth.d.ts +4 -4
- package/dist/auth.js +50 -10
- package/dist/cache.d.ts +27 -6
- package/dist/cache.js +129 -66
- package/dist/composeMiddleware.js +2 -2
- package/dist/config.d.ts +3 -1
- package/dist/config.js +1 -0
- package/dist/context.d.ts +1 -1
- package/dist/context.js +2 -2
- package/dist/cookie-consent.d.ts +1 -1
- package/dist/csrf.d.ts +1 -1
- package/dist/csrf.js +4 -0
- package/dist/email.d.ts +1 -1
- package/dist/faktory.d.ts +1 -1
- package/dist/listen.js +1 -2
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +2 -2
- package/dist/manage.js +3 -3
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.js +81 -15
- package/dist/rate-limit.d.ts +1 -1
- package/dist/redis.d.ts +1 -1
- package/dist/routes.d.ts +2 -2
- package/dist/slack.d.ts +1 -1
- package/dist/utils.d.ts +0 -3
- package/dist/utils.js +0 -1
- package/dist/vite/defineViteConfig.d.ts +1 -1
- package/dist/vite/ssr.d.ts +1 -1
- package/dist/vite/ssr.js +1 -1
- package/dist/vitest/testClient.d.ts +5 -5
- package/package.json +15 -16
package/dist/cache.js
CHANGED
|
@@ -1,62 +1,116 @@
|
|
|
1
1
|
import { config } from '@devmoods/config';
|
|
2
2
|
import {} from './redis.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
if (
|
|
21
|
-
await redis.
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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, (
|
|
105
|
-
if (value
|
|
106
|
-
return
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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) ||
|
|
12
|
+
const requestId = getRequestId(req) || randomUUID();
|
|
13
13
|
requestContext.run(new Map(), () => {
|
|
14
14
|
setRequestContext(REQUEST_ID_CONTEXT_KEY, requestId);
|
|
15
15
|
next();
|
package/dist/cookie-consent.d.ts
CHANGED
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
63
|
+
// oxlint-disable-next-line no-console
|
|
64
64
|
console.log(JSON.stringify(config, null, 2));
|
|
65
65
|
return;
|
|
66
66
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/middleware.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
};
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -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;
|