@devmoods/express-extras 0.49.4 → 0.51.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.d.ts +5 -1
- package/dist/auth/emailAndPassword.js +4 -1
- package/dist/auth/types.d.ts +1 -1
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +18 -8
- package/dist/cache.js +8 -5
- package/dist/channels.d.ts +8 -0
- package/dist/channels.js +66 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/context.d.ts +2 -0
- package/dist/context.js +1 -1
- package/dist/faktory.d.ts +0 -1
- package/dist/faktory.js +0 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/listen.js +2 -2
- package/dist/manage.js +10 -2
- package/dist/middleware.d.ts +26 -2
- package/dist/middleware.js +12 -6
- package/dist/validate.d.ts +7 -4
- package/dist/validate.js +11 -0
- package/dist/vite/defineViteConfig.js +3 -3
- package/dist/vite/ssr.d.ts +3 -3
- package/dist/vite/ssr.js +16 -4
- package/dist/vite/types.d.ts +5 -4
- package/dist/vite/types.js +1 -1
- package/package.json +15 -13
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type AnyUser, type AuthProvider } from './types.js';
|
|
2
|
+
export interface AuthPasswordChangedMetadata {
|
|
3
|
+
ip: string;
|
|
4
|
+
userAgent: string;
|
|
5
|
+
}
|
|
2
6
|
export interface EmailAndPasswordOptions<UserModel = AnyUser> {
|
|
3
7
|
login: (email: string, verifyPassword: (realPassword: string) => Promise<string>) => Promise<UserModel>;
|
|
4
8
|
onPasswordResetRequest?(user: UserModel, temporaryToken: string): void | Promise<void>;
|
|
5
|
-
onPasswordChanged?(user: UserModel, newPassword: string): Promise<void>;
|
|
9
|
+
onPasswordChanged?(user: UserModel, newPassword: string, metadata: AuthPasswordChangedMetadata): Promise<void>;
|
|
6
10
|
onEmailVerificationRequest?(user: UserModel, temporaryToken: string): Promise<void>;
|
|
7
11
|
onEmailVerified?(user: UserModel): Promise<void>;
|
|
8
12
|
}
|
|
@@ -52,7 +52,10 @@ export function EmailAndPasswordAuthProvider(options) {
|
|
|
52
52
|
throw Unauthorized('Invalid token');
|
|
53
53
|
}
|
|
54
54
|
context.setCurrentUser(user);
|
|
55
|
-
await options.onPasswordChanged?.(user, await hashPassword(password)
|
|
55
|
+
await options.onPasswordChanged?.(user, await hashPassword(password), {
|
|
56
|
+
ip: req.ip ?? '',
|
|
57
|
+
userAgent: req.headers['user-agent'] ?? '',
|
|
58
|
+
});
|
|
56
59
|
logger.info('user changed password');
|
|
57
60
|
return 200;
|
|
58
61
|
}));
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
|
|
|
38
38
|
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
39
39
|
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
|
40
40
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
41
|
-
login: (req: Request, res: Response, user: UserModel) => Promise<AccessToken>;
|
|
41
|
+
login: (req: Request, res: Response, user: UserModel, isRefresh?: boolean) => Promise<AccessToken>;
|
|
42
42
|
getUserById(id: string): Promise<UserModel>;
|
|
43
43
|
jitter(duration?: number): Promise<void>;
|
|
44
44
|
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface CreateAuth<UserModel extends AnyUser> {
|
|
|
9
9
|
createRouter: () => Router;
|
|
10
10
|
useCurrentUser: () => UserModel | undefined;
|
|
11
11
|
middleware: (options?: AuthMiddlewareOptions<UserModel>) => RequestHandler;
|
|
12
|
+
before: () => RequestHandler;
|
|
13
|
+
isAuthorized: (fn?: (user: UserModel) => boolean) => RequestHandler;
|
|
12
14
|
createTemporaryToken(user: UserModel): Promise<string>;
|
|
13
15
|
verifyTemporaryToken(token: string, user: UserModel): Promise<void>;
|
|
14
16
|
decodeTemporaryToken(token: string): DecodedTemporaryToken;
|
package/dist/auth.js
CHANGED
|
@@ -16,7 +16,7 @@ export * from './auth/emailAndPassword.js';
|
|
|
16
16
|
export * from './auth/passwordless.js';
|
|
17
17
|
export * from './auth/token.js';
|
|
18
18
|
export class RedisTokenStorage extends TokenStorage {
|
|
19
|
-
constructor(redis, keyPrefix =
|
|
19
|
+
constructor(redis, keyPrefix = `${config.value.REDIS_KEY_PREFIX}auth:`) {
|
|
20
20
|
super();
|
|
21
21
|
this.redis = redis;
|
|
22
22
|
this.keyPrefix = keyPrefix;
|
|
@@ -37,17 +37,19 @@ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'
|
|
|
37
37
|
export function createAuth({ getUserById, filterUser, tokenStorage, providers, loginUrl = '/', onUserLoggedIn, sessionTokenVersion = '1', }) {
|
|
38
38
|
const useCurrentUser = () => useContext('currentUser');
|
|
39
39
|
const setCurrentUser = (user) => setContext('currentUser', user);
|
|
40
|
-
const login = async (req, res, user) => {
|
|
40
|
+
const login = async (req, res, user, isRefresh = false) => {
|
|
41
41
|
const accessToken = createAccessToken({ uid: user.id }, sessionTokenVersion);
|
|
42
42
|
const sessionId = sha256(accessToken.token);
|
|
43
43
|
await tokenStorage.set(sessionId, String(user.id), accessToken.expiresIn);
|
|
44
44
|
setAccessTokenCookie(res, accessToken.token);
|
|
45
45
|
setCurrentUser(user);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
if (!isRefresh) {
|
|
47
|
+
await onUserLoggedIn?.(user, {
|
|
48
|
+
ip: req.ip ?? '',
|
|
49
|
+
userAgent: req.headers['user-agent'] ?? '',
|
|
50
|
+
sessionId,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
51
53
|
return accessToken;
|
|
52
54
|
};
|
|
53
55
|
const logout = async (res, sessionId) => {
|
|
@@ -106,7 +108,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
106
108
|
setAccessTokenCookie(res, null);
|
|
107
109
|
throw Unauthorized();
|
|
108
110
|
}
|
|
109
|
-
const { token } = await providerContext.login(req, res, user);
|
|
111
|
+
const { token } = await providerContext.login(req, res, user, true);
|
|
110
112
|
logger.info('refreshed token');
|
|
111
113
|
return { token, user: filterUser(user) };
|
|
112
114
|
}));
|
|
@@ -153,9 +155,15 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
153
155
|
await logout(res, token);
|
|
154
156
|
throw new InvalidToken('User not found');
|
|
155
157
|
}
|
|
158
|
+
const tokenLifetimeMs = decoded.exp * 1000 - decoded.iat * 1000;
|
|
159
|
+
const remainingMs = decoded.exp * 1000 - Date.now();
|
|
160
|
+
const shouldRefresh = remainingMs < tokenLifetimeMs * 0.25;
|
|
156
161
|
if (options.isAuthorized && !(await options.isAuthorized(user))) {
|
|
157
162
|
throw new PermissionDenied('Permission denied');
|
|
158
163
|
}
|
|
164
|
+
if (shouldRefresh) {
|
|
165
|
+
await providerContext.login(req, res, user, true);
|
|
166
|
+
}
|
|
159
167
|
setCurrentUser(user);
|
|
160
168
|
next();
|
|
161
169
|
}
|
|
@@ -225,6 +233,8 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
225
233
|
await tokenStorage.invalidate(token);
|
|
226
234
|
}
|
|
227
235
|
return {
|
|
236
|
+
before: () => middleware(false),
|
|
237
|
+
isAuthorized: (fn) => middleware({ isAuthorized: fn || (() => true) }),
|
|
228
238
|
createRouter,
|
|
229
239
|
useCurrentUser,
|
|
230
240
|
middleware,
|
package/dist/cache.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
+
import { config } from './config.js';
|
|
1
2
|
import {} from './redis.js';
|
|
2
3
|
export function createRedisCacheAdapter(redis, options = {}) {
|
|
3
|
-
const { keyPrefix =
|
|
4
|
-
const
|
|
4
|
+
const { keyPrefix = `${config.value.REDIS_KEY_PREFIX}cache:` } = options;
|
|
5
|
+
const withKeyPrefix = (key) => `${keyPrefix}${key}`;
|
|
5
6
|
return {
|
|
6
7
|
async get(key) {
|
|
7
|
-
const value = await redis.get(
|
|
8
|
+
const value = await redis.get(withKeyPrefix(key));
|
|
8
9
|
if (value == null) {
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
12
|
return JSON.parse(value);
|
|
12
13
|
},
|
|
13
14
|
async set(key, value, expires) {
|
|
14
|
-
await redis.set(
|
|
15
|
+
await redis.set(withKeyPrefix(key), JSON.stringify(value), {
|
|
16
|
+
PX: expires,
|
|
17
|
+
});
|
|
15
18
|
},
|
|
16
19
|
async clear(key) {
|
|
17
20
|
if (key) {
|
|
18
|
-
await redis.del(
|
|
21
|
+
await redis.del(withKeyPrefix(key));
|
|
19
22
|
}
|
|
20
23
|
const keys = await redis.keys(`${keyPrefix}*`);
|
|
21
24
|
for (const existingKey of keys) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Request } from 'express';
|
|
2
|
+
import { type RedisClient } from './redis.js';
|
|
3
|
+
export declare function createEventStream<Channels extends string>(redis: RedisClient): {
|
|
4
|
+
sse: ({ channels, }: {
|
|
5
|
+
channels: (req: Request) => string[] | Promise<string[]>;
|
|
6
|
+
}) => import("express").RequestHandler<Record<string, string>, Record<string, any>, unknown, Record<string, any>, Record<string, any>>;
|
|
7
|
+
publish: (channel: Channels, message: unknown) => Promise<number>;
|
|
8
|
+
};
|
package/dist/channels.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {} from 'express';
|
|
2
|
+
import { config } from './config.js';
|
|
3
|
+
import { requestContext } from './context.js';
|
|
4
|
+
import { getLogger } from './logger.js';
|
|
5
|
+
import { route } from './middleware.js';
|
|
6
|
+
import {} from './redis.js';
|
|
7
|
+
const logger = getLogger();
|
|
8
|
+
export function createEventStream(redis) {
|
|
9
|
+
const keyPrefix = `${config.value.REDIS_KEY_PREFIX}events:`;
|
|
10
|
+
const withKeyPrefix = (key) => `${keyPrefix}${key}`;
|
|
11
|
+
const sse = ({ channels, }) => {
|
|
12
|
+
return route(async (req, res) => {
|
|
13
|
+
const headers = {
|
|
14
|
+
'Content-Type': 'text/event-stream',
|
|
15
|
+
'Cache-Control': 'no-cache',
|
|
16
|
+
Connection: 'keep-alive',
|
|
17
|
+
};
|
|
18
|
+
res.writeHead(200, headers);
|
|
19
|
+
res.write('retry: 15000\n');
|
|
20
|
+
const heartbeat = setInterval(() => {
|
|
21
|
+
try {
|
|
22
|
+
if (!res.writableEnded) {
|
|
23
|
+
res.write('event: heartbeat\n');
|
|
24
|
+
res.write('data: ping\n\n');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
logger.error(error);
|
|
29
|
+
}
|
|
30
|
+
}, 15000);
|
|
31
|
+
const store = requestContext.getStore();
|
|
32
|
+
const listener = (value) => {
|
|
33
|
+
try {
|
|
34
|
+
requestContext.run(store, () => {
|
|
35
|
+
if (!res.writableEnded) {
|
|
36
|
+
res.write('event: data\n');
|
|
37
|
+
res.write(`data: ${JSON.stringify(value)}\n\n`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.error(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const subscriber = redis.duplicate();
|
|
46
|
+
await subscriber.connect();
|
|
47
|
+
const _channels = await channels(req);
|
|
48
|
+
await subscriber.subscribe(_channels.map(withKeyPrefix), (message) => {
|
|
49
|
+
listener(JSON.parse(message));
|
|
50
|
+
});
|
|
51
|
+
logger.info('event stream subscribe', { channels: _channels });
|
|
52
|
+
res.on('close', () => {
|
|
53
|
+
requestContext.run(store, () => {
|
|
54
|
+
logger.info('event stream close');
|
|
55
|
+
clearInterval(heartbeat);
|
|
56
|
+
subscriber.quit();
|
|
57
|
+
res.end();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
const publish = (channel, message) => {
|
|
63
|
+
return redis.publish(withKeyPrefix(channel), JSON.stringify(message));
|
|
64
|
+
};
|
|
65
|
+
return { sse, publish };
|
|
66
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare const config: {
|
|
|
7
7
|
AUTH_TEMP_TOKEN_EXPIRES_IN: number;
|
|
8
8
|
AUTH_SECRET: string;
|
|
9
9
|
REDIS_URL: string;
|
|
10
|
+
REDIS_KEY_PREFIX: string;
|
|
10
11
|
POSTGRES_URL: string;
|
|
11
12
|
SENTRY_DSN: string;
|
|
12
13
|
LOG_LEVEL: string;
|
|
@@ -37,6 +38,7 @@ export declare function extendConfig<T extends Record<string, unknown>>(appConfi
|
|
|
37
38
|
AUTH_TEMP_TOKEN_EXPIRES_IN: number;
|
|
38
39
|
AUTH_SECRET: string;
|
|
39
40
|
REDIS_URL: string;
|
|
41
|
+
REDIS_KEY_PREFIX: string;
|
|
40
42
|
POSTGRES_URL: string;
|
|
41
43
|
SENTRY_DSN: string;
|
|
42
44
|
LOG_LEVEL: string;
|
package/dist/config.js
CHANGED
|
@@ -10,6 +10,7 @@ const baseConfig = {
|
|
|
10
10
|
AUTH_TEMP_TOKEN_EXPIRES_IN: readEnv.number('AUTH_TEMP_TOKEN_EXPIRES', 3600),
|
|
11
11
|
AUTH_SECRET: readEnv('AUTH_JWT_SECRET', ''),
|
|
12
12
|
REDIS_URL: readEnv('REDIS_URL', ''),
|
|
13
|
+
REDIS_KEY_PREFIX: readEnv('REDIS_KEY_PREFIX', ''),
|
|
13
14
|
POSTGRES_URL: readEnv('POSTGRES_URL', ''),
|
|
14
15
|
SENTRY_DSN: readEnv('SENTRY_DSN', ''),
|
|
15
16
|
LOG_LEVEL: readEnv('LOG_LEVEL', 'INFO'),
|
package/dist/context.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
1
2
|
import { type Request, type RequestHandler } from 'express';
|
|
3
|
+
export declare const requestContext: AsyncLocalStorage<Map<string, any>>;
|
|
2
4
|
export interface RequestContextOptions {
|
|
3
5
|
getRequestId?: (req: Request) => string | undefined;
|
|
4
6
|
}
|
package/dist/context.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import {} from 'express';
|
|
3
3
|
import { v4 as uuid } from 'uuid';
|
|
4
|
-
const requestContext = new AsyncLocalStorage();
|
|
4
|
+
export const requestContext = new AsyncLocalStorage();
|
|
5
5
|
const REQUEST_ID = 'requestId';
|
|
6
6
|
/**
|
|
7
7
|
* Middleware for setting a `requestId` on the request context.
|
package/dist/faktory.d.ts
CHANGED
|
@@ -14,7 +14,6 @@ export declare function createJobs<GlobalContext extends AnyJobContext>({ queueN
|
|
|
14
14
|
$job: boolean;
|
|
15
15
|
jobName: string;
|
|
16
16
|
options: JobOptions;
|
|
17
|
-
readonly executionCount: number;
|
|
18
17
|
delay(args?: Parameters<Fn>[0]): Promise<`${string}-${string}-${string}-${string}-${string}`>;
|
|
19
18
|
execute(args: Parameters<Fn>[0], context: GlobalContext): Promise<any>;
|
|
20
19
|
};
|
package/dist/faktory.js
CHANGED
|
@@ -14,7 +14,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
14
14
|
throw new Error('job() must get a function as the first or second argument');
|
|
15
15
|
}
|
|
16
16
|
const jobName = fn.name;
|
|
17
|
-
let executionCount = 0;
|
|
18
17
|
if (!jobName) {
|
|
19
18
|
throw new Error('Can not create job without a name');
|
|
20
19
|
}
|
|
@@ -22,9 +21,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
22
21
|
$job: true,
|
|
23
22
|
jobName,
|
|
24
23
|
options,
|
|
25
|
-
get executionCount() {
|
|
26
|
-
return executionCount;
|
|
27
|
-
},
|
|
28
24
|
async delay(args) {
|
|
29
25
|
const jid = randomUUID();
|
|
30
26
|
try {
|
|
@@ -49,7 +45,6 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
49
45
|
async execute(args, context) {
|
|
50
46
|
try {
|
|
51
47
|
const result = await fn(args, context);
|
|
52
|
-
executionCount += 1;
|
|
53
48
|
return result;
|
|
54
49
|
}
|
|
55
50
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/listen.js
CHANGED
|
@@ -6,7 +6,7 @@ import {} from 'express';
|
|
|
6
6
|
import { config } from './config.js';
|
|
7
7
|
import { BadRequest } from './errors.js';
|
|
8
8
|
import { getLogger } from './logger.js';
|
|
9
|
-
import {
|
|
9
|
+
import { route } from './middleware.js';
|
|
10
10
|
import { envPort, startTimer } from './utils.js';
|
|
11
11
|
import { validateQuery } from './validate.js';
|
|
12
12
|
const logger = getLogger('http');
|
|
@@ -103,7 +103,7 @@ export function createExpressServer(app, options = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
const healthTypes = ['live', 'ready'];
|
|
105
105
|
if (!app.get('__healthAttached')) {
|
|
106
|
-
app.get('/health/:type?',
|
|
106
|
+
app.get('/health/:type?', validateQuery({
|
|
107
107
|
type: 'object',
|
|
108
108
|
properties: {
|
|
109
109
|
type: { type: 'string', enum: ['live', 'ready'] },
|
package/dist/manage.js
CHANGED
|
@@ -46,8 +46,16 @@ export function registerDefaultCommands(app) {
|
|
|
46
46
|
}));
|
|
47
47
|
});
|
|
48
48
|
registerManagementCommand((command, cli) => {
|
|
49
|
-
command('show-config')
|
|
50
|
-
|
|
49
|
+
command('show-config')
|
|
50
|
+
.option('--json', 'Output JSON')
|
|
51
|
+
.action(cli.wrap(async (options) => {
|
|
52
|
+
if (options.json) {
|
|
53
|
+
console.log(JSON.stringify(config.value, null, 2));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const [key, value] of Object.entries(config.value)) {
|
|
57
|
+
cli.writeLine(`${key}=${value}\n`);
|
|
58
|
+
}
|
|
51
59
|
}));
|
|
52
60
|
});
|
|
53
61
|
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -13,18 +13,42 @@ export interface BeforeMiddlewareCSPDirectives {
|
|
|
13
13
|
[key: string]: ContentSecurityPolicyDirectiveValue[];
|
|
14
14
|
}
|
|
15
15
|
export interface BeforeMiddlewareOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Enable cookie parsing
|
|
18
|
+
*/
|
|
16
19
|
cookies?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Enable helmet security headers
|
|
22
|
+
*
|
|
23
|
+
* https://github.com/helmetjs/helmet
|
|
24
|
+
*/
|
|
17
25
|
helmet?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Enable JSON body parsing
|
|
28
|
+
*/
|
|
18
29
|
json?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Enable URL encoded body parsing
|
|
32
|
+
*/
|
|
33
|
+
urlencoded?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Enable CSRF protection
|
|
36
|
+
*/
|
|
19
37
|
csrf?: boolean | CsrfMiddlewareOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Allow unsafe inline scripts
|
|
40
|
+
*/
|
|
20
41
|
allowUnsafeInlineScripts?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Custom CSP directives
|
|
44
|
+
*/
|
|
21
45
|
cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
|
|
22
46
|
}
|
|
23
47
|
export declare function useCspNonce(): string;
|
|
24
48
|
/**
|
|
25
49
|
* Register middleware that must come before the app routes.
|
|
26
50
|
*/
|
|
27
|
-
export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
51
|
+
export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, urlencoded: useUrlencoded, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
28
52
|
/**
|
|
29
53
|
* Middleware that sends a 404
|
|
30
54
|
*
|
|
@@ -43,4 +67,4 @@ type ResponseWrapper<TResponseBody extends AnyResponseBody> = TResponseBody | TR
|
|
|
43
67
|
/**
|
|
44
68
|
* Alias of `route()`
|
|
45
69
|
*/
|
|
46
|
-
export declare function asyncMiddleware<RequestBody, RequestQuery extends Record<string, any> = Record<string, any>, ResponseBody extends AnyResponseBody = Record<string, any
|
|
70
|
+
export declare function asyncMiddleware<RequestBody, RequestQuery extends Record<string, any> = Record<string, any>, ResponseBody extends AnyResponseBody = Record<string, any>, ParamKeys extends string = string>(fn: (req: Request<Record<ParamKeys, string>, ResponseBody, RequestBody, RequestQuery, Record<string, any>>, res: Response<ResponseBody, object>, next: NextFunction) => ResponseWrapper<ResponseBody> | Promise<ResponseWrapper<ResponseBody>>): RequestHandler<Record<string, string>, ResponseBody, RequestBody, RequestQuery>;
|
package/dist/middleware.js
CHANGED
|
@@ -20,7 +20,7 @@ export function useCspNonce() {
|
|
|
20
20
|
/**
|
|
21
21
|
* Register middleware that must come before the app routes.
|
|
22
22
|
*/
|
|
23
|
-
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
23
|
+
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, urlencoded: useUrlencoded = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
24
24
|
return compose([
|
|
25
25
|
withRequestContext(),
|
|
26
26
|
typeof useCsrf === 'object' && useCsrf != null
|
|
@@ -29,10 +29,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
29
29
|
? csrf()
|
|
30
30
|
: undefined,
|
|
31
31
|
logRequestDuration(),
|
|
32
|
-
(
|
|
33
|
-
setContext(CSP_NONCE_KEY, crypto.randomBytes(16).toString('base64'));
|
|
34
|
-
next();
|
|
35
|
-
}),
|
|
32
|
+
setCspNonce(),
|
|
36
33
|
useHelmet &&
|
|
37
34
|
helmet({
|
|
38
35
|
contentSecurityPolicy: {
|
|
@@ -65,7 +62,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
65
62
|
},
|
|
66
63
|
}),
|
|
67
64
|
useJson && jsonParser(),
|
|
68
|
-
urlencodedParser({ extended: true }),
|
|
65
|
+
useUrlencoded && urlencodedParser({ extended: true }),
|
|
69
66
|
(cookies || useCsrf) && cookieParser(),
|
|
70
67
|
analyticsProxy(),
|
|
71
68
|
].filter(Boolean));
|
|
@@ -106,6 +103,15 @@ export function notFound() {
|
|
|
106
103
|
next(NotFound(`Could not ${req.method} ${req.path}`));
|
|
107
104
|
};
|
|
108
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Set a CSP nonce for the request
|
|
108
|
+
*/
|
|
109
|
+
function setCspNonce() {
|
|
110
|
+
return (req, res, next) => {
|
|
111
|
+
setContext(CSP_NONCE_KEY, crypto.randomBytes(16).toString('base64'));
|
|
112
|
+
next();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
109
115
|
/**
|
|
110
116
|
* In production, we could directly proxy the url
|
|
111
117
|
* to an analytics service from nginx, but in dev
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Options } from 'ajv';
|
|
2
|
+
import { Type } from 'arktype';
|
|
2
3
|
import { type RequestHandler } from 'express';
|
|
3
4
|
export type ValidationSchema = {
|
|
4
5
|
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'null';
|
|
@@ -27,9 +28,10 @@ export type ValidationSchema = {
|
|
|
27
28
|
title?: string;
|
|
28
29
|
description?: string;
|
|
29
30
|
} | boolean;
|
|
30
|
-
|
|
31
|
-
export declare function
|
|
32
|
-
export declare function
|
|
31
|
+
type AnySchema = Type | ValidationSchema;
|
|
32
|
+
export declare function validateBody(schema: AnySchema): RequestHandler;
|
|
33
|
+
export declare function validateQuery(schema: AnySchema): RequestHandler;
|
|
34
|
+
export declare function validateParams(schema: AnySchema): RequestHandler;
|
|
33
35
|
export type ValidationResult = {
|
|
34
36
|
isValid: true;
|
|
35
37
|
errors: null;
|
|
@@ -48,4 +50,5 @@ export declare function compileValidateSchema(schema: ValidationSchema, options?
|
|
|
48
50
|
[key: string]: any[];
|
|
49
51
|
};
|
|
50
52
|
};
|
|
51
|
-
export declare function validateSchema(data: any, schema:
|
|
53
|
+
export declare function validateSchema(data: any, schema: AnySchema, options?: Options): ValidationResult;
|
|
54
|
+
export {};
|
package/dist/validate.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import _Ajv, {} from 'ajv';
|
|
2
|
+
import { Type } from 'arktype';
|
|
2
3
|
import {} from 'express';
|
|
3
4
|
import { BadRequest } from './errors.js';
|
|
4
5
|
const Ajv = _Ajv;
|
|
6
|
+
const toValidationSchema = (schema) => {
|
|
7
|
+
if (schema instanceof Type) {
|
|
8
|
+
return schema.toJsonSchema();
|
|
9
|
+
}
|
|
10
|
+
return schema;
|
|
11
|
+
};
|
|
5
12
|
export function validateBody(schema) {
|
|
13
|
+
schema = toValidationSchema(schema);
|
|
6
14
|
const validate = compileValidateSchema(schema);
|
|
7
15
|
const middleware = (req, res, next) => {
|
|
8
16
|
const result = validate(req.body);
|
|
@@ -15,6 +23,7 @@ export function validateBody(schema) {
|
|
|
15
23
|
return middleware;
|
|
16
24
|
}
|
|
17
25
|
export function validateQuery(schema) {
|
|
26
|
+
schema = toValidationSchema(schema);
|
|
18
27
|
const validate = compileValidateSchema(schema, { coerceTypes: true });
|
|
19
28
|
const middleware = (req, res, next) => {
|
|
20
29
|
const result = validate(req.query);
|
|
@@ -27,6 +36,7 @@ export function validateQuery(schema) {
|
|
|
27
36
|
return middleware;
|
|
28
37
|
}
|
|
29
38
|
export function validateParams(schema) {
|
|
39
|
+
schema = toValidationSchema(schema);
|
|
30
40
|
const validate = compileValidateSchema(schema, { coerceTypes: true });
|
|
31
41
|
const middleware = (req, res, next) => {
|
|
32
42
|
const result = validate(req.params);
|
|
@@ -68,6 +78,7 @@ export function compileValidateSchema(schema, options = {}) {
|
|
|
68
78
|
};
|
|
69
79
|
}
|
|
70
80
|
export function validateSchema(data, schema, options = {}) {
|
|
81
|
+
schema = toValidationSchema(schema);
|
|
71
82
|
const ajv = createAjv(options);
|
|
72
83
|
const valid = ajv.validate(schema, data);
|
|
73
84
|
if (valid) {
|
|
@@ -4,7 +4,7 @@ import react from '@vitejs/plugin-react';
|
|
|
4
4
|
import Sonda from 'sonda/vite';
|
|
5
5
|
import { defineConfig } from 'vite';
|
|
6
6
|
import { readEnv } from '../utils.js';
|
|
7
|
-
export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, analyze =
|
|
7
|
+
export function defineViteConfig({ base = '', reactCompiler = false, ssr = false, analyze = readEnv.boolean('VITE_ANALYZE', false), }) {
|
|
8
8
|
const port = readEnv.number('VITE_PORT', 5173);
|
|
9
9
|
return defineConfig({
|
|
10
10
|
plugins: [
|
|
@@ -28,7 +28,7 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
|
|
|
28
28
|
build: {
|
|
29
29
|
chunkSizeWarningLimit: 750,
|
|
30
30
|
manifest: true,
|
|
31
|
-
sourcemap:
|
|
31
|
+
sourcemap: analyze,
|
|
32
32
|
// Set via cli arg --outDir for ssr
|
|
33
33
|
outDir: ssr ? undefined : 'dist/client',
|
|
34
34
|
rollupOptions: ssr
|
|
@@ -39,7 +39,7 @@ export function defineViteConfig({ base = '', reactCompiler = false, ssr = false
|
|
|
39
39
|
}
|
|
40
40
|
: {
|
|
41
41
|
input: {
|
|
42
|
-
index: path.resolve(process.cwd(), '
|
|
42
|
+
index: path.resolve(process.cwd(), 'index.html'),
|
|
43
43
|
},
|
|
44
44
|
},
|
|
45
45
|
},
|
package/dist/vite/ssr.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface SsrMiddlewareOptions<InitialData = unknown> {
|
|
2
2
|
root?: string;
|
|
3
|
-
getInitialData?: (req:
|
|
3
|
+
getInitialData?: (req: Request) => Promise<InitialData>;
|
|
4
4
|
useDevServer?: boolean;
|
|
5
5
|
}
|
|
6
|
-
export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?:
|
|
6
|
+
export declare function createSsrMiddleware<InitialData = unknown>({ root, getInitialData, useDevServer, }?: SsrMiddlewareOptions<InitialData>): Promise<import("compose-middleware").RequestHandler<unknown, unknown, void>>;
|
package/dist/vite/ssr.js
CHANGED
|
@@ -4,8 +4,10 @@ import express, {} from 'express';
|
|
|
4
4
|
import { createServer as createViteServer } from 'vite';
|
|
5
5
|
import {} from './types.js';
|
|
6
6
|
import { config } from '../config.js';
|
|
7
|
+
import { generateCsrfToken } from '../csrf.js';
|
|
7
8
|
import { composeMiddleware, route, useCspNonce } from '../middleware.js';
|
|
8
|
-
|
|
9
|
+
import { createFetchRequest } from '../webapi.js';
|
|
10
|
+
export async function createSsrMiddleware({ root = process.cwd(), getInitialData, useDevServer = config.value.VITE_USE_DEV_SERVER, } = {}) {
|
|
9
11
|
const resolve = (p) => path.resolve(root, p);
|
|
10
12
|
let vite;
|
|
11
13
|
const middlewares = [];
|
|
@@ -31,12 +33,13 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
31
33
|
render = result.render;
|
|
32
34
|
}
|
|
33
35
|
const nonce = useCspNonce();
|
|
34
|
-
const rendered = await render(req,
|
|
36
|
+
const rendered = await render(createFetchRequest(req), {
|
|
35
37
|
useDevServer,
|
|
36
|
-
getInitialData,
|
|
38
|
+
getInitialData: getInitialData || (async () => ({})),
|
|
37
39
|
placeholders: `
|
|
38
40
|
{{ ssr-title }}
|
|
39
41
|
{{ ssr-meta }}
|
|
42
|
+
{{ ssr-fouc }}
|
|
40
43
|
{{ ssr-preload }}
|
|
41
44
|
{{ ssr-js }}
|
|
42
45
|
{{ ssr-css }}
|
|
@@ -49,6 +52,8 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
49
52
|
window.$RefreshSig$ = () => (type) => type
|
|
50
53
|
window.__vite_plugin_react_preamble_installed__ = true
|
|
51
54
|
`,
|
|
55
|
+
publicUrl: new URL(config.value.PUBLIC_URL),
|
|
56
|
+
csrfToken: generateCsrfToken(req, res),
|
|
52
57
|
});
|
|
53
58
|
const readManifest = async () => {
|
|
54
59
|
if (useDevServer) {
|
|
@@ -58,7 +63,14 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
|
|
|
58
63
|
return getAssetLinks(manifestPath, 'src/entry-client.tsx');
|
|
59
64
|
};
|
|
60
65
|
let html = rendered.html;
|
|
61
|
-
const context = Object.assign({
|
|
66
|
+
const context = Object.assign({
|
|
67
|
+
fouc: `
|
|
68
|
+
<style id="fouc">body { visibility: hidden; opacity: 0.0 }</style>
|
|
69
|
+
<script nonce="${nonce}">
|
|
70
|
+
setTimeout(() => { document.getElementById('fouc').remove(); }, 2000);
|
|
71
|
+
</script>
|
|
72
|
+
`,
|
|
73
|
+
}, rendered.context, await readManifest());
|
|
62
74
|
const ssrPlaceholders = Object.fromEntries(Object.entries(context).map(([k, v]) => [`ssr-${k}`, v.toString()]));
|
|
63
75
|
// Add nonce to all bare script tags
|
|
64
76
|
html = html.replace(/<script>/g, `<script nonce="${nonce}">`);
|
package/dist/vite/types.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
export interface SsrRenderOptions {
|
|
1
|
+
export interface SsrRenderOptions<InitialData = unknown> {
|
|
3
2
|
useDevServer: boolean;
|
|
4
3
|
placeholders: string;
|
|
5
4
|
nonce: string;
|
|
5
|
+
csrfToken: string;
|
|
6
6
|
preamble: string;
|
|
7
|
-
getInitialData: (req:
|
|
7
|
+
getInitialData: (req: Request) => Promise<InitialData>;
|
|
8
|
+
publicUrl: URL;
|
|
8
9
|
}
|
|
9
|
-
export type EntryRenderFn = (req: Request,
|
|
10
|
+
export type EntryRenderFn = (req: Request, options: SsrRenderOptions) => Promise<{
|
|
10
11
|
html: string;
|
|
11
12
|
context: any;
|
|
12
13
|
}>;
|
package/dist/vite/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
],
|
|
45
45
|
"license": "ISC",
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"
|
|
47
|
+
"arktype": "^2.0.2",
|
|
48
|
+
"@devmoods/eslint-config": "^2.6.0",
|
|
48
49
|
"@types/koa-compose": "^3.2.8",
|
|
49
|
-
"@types/node": "22.10.
|
|
50
|
+
"@types/node": "22.10.10",
|
|
50
51
|
"@types/supertest": "^6.0.2",
|
|
51
52
|
"@vitejs/plugin-react": "^4.3.4",
|
|
52
|
-
"@vitest/coverage-v8": "^
|
|
53
|
+
"@vitest/coverage-v8": "^3.0.4",
|
|
53
54
|
"cookiejar": "^2.1.4",
|
|
54
55
|
"eslint": "^9.18.0",
|
|
55
56
|
"faktory-worker": "^4.6.0",
|
|
@@ -58,34 +59,34 @@
|
|
|
58
59
|
"ts-node": "10.9.2",
|
|
59
60
|
"tsx": "^4.19.2",
|
|
60
61
|
"typescript": "5.7.3",
|
|
61
|
-
"vite": "^6.0.
|
|
62
|
-
"vitest": "^
|
|
62
|
+
"vite": "^6.0.11",
|
|
63
|
+
"vitest": "^3.0.4",
|
|
63
64
|
"vitest-fail-on-console": "^0.7.1"
|
|
64
65
|
},
|
|
65
66
|
"dependencies": {
|
|
66
67
|
"@devmoods/fetch": "^4.0.1",
|
|
67
|
-
"@sentry/node": "^8.
|
|
68
|
-
"@sentry/profiling-node": "^8.
|
|
68
|
+
"@sentry/node": "^8.51.0",
|
|
69
|
+
"@sentry/profiling-node": "^8.51.0",
|
|
69
70
|
"@types/bcrypt": "^5.0.2",
|
|
70
71
|
"@types/cookie-parser": "^1.4.8",
|
|
71
72
|
"@types/express": "^5.0.0",
|
|
72
|
-
"@types/jsonwebtoken": "^9.0.
|
|
73
|
+
"@types/jsonwebtoken": "^9.0.8",
|
|
73
74
|
"@types/nodemailer": "^6.4.17",
|
|
74
|
-
"@types/pg": "^8.11.
|
|
75
|
+
"@types/pg": "^8.11.11",
|
|
75
76
|
"@types/superagent": "^8.1.9",
|
|
76
77
|
"@types/uuid": "^10.0.0",
|
|
77
78
|
"ajv": "^8.17.1",
|
|
78
79
|
"argon2": "^0.41.1",
|
|
79
80
|
"bcrypt": "^5.1.1",
|
|
80
81
|
"chalk": "^5.4.1",
|
|
81
|
-
"commander": "^13.
|
|
82
|
+
"commander": "^13.1.0",
|
|
82
83
|
"compose-middleware": "^5.0.1",
|
|
83
84
|
"cookie-parser": "^1.4.7",
|
|
84
85
|
"dotenv": "^16.4.7",
|
|
85
86
|
"express": "^4.21.2",
|
|
86
87
|
"helmet": "^8.0.0",
|
|
87
88
|
"jsonwebtoken": "^9.0.2",
|
|
88
|
-
"nodemailer": "^6.
|
|
89
|
+
"nodemailer": "^6.10.0",
|
|
89
90
|
"pg": "^8.13.1",
|
|
90
91
|
"redis": "^4.7.0",
|
|
91
92
|
"sonda": "^0.7.1",
|
|
@@ -98,7 +99,8 @@
|
|
|
98
99
|
"peerDependencies": {
|
|
99
100
|
"@vitejs/plugin-react": ">=4.3.4",
|
|
100
101
|
"faktory-worker": ">=4.5.1",
|
|
101
|
-
"vite": ">=6.0.5"
|
|
102
|
+
"vite": ">=6.0.5",
|
|
103
|
+
"arktype": ">=2.0.2"
|
|
102
104
|
},
|
|
103
105
|
"engines": {
|
|
104
106
|
"node": ">=20 <24"
|