@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.
@@ -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
  }));
@@ -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 = 'auth:') {
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
- await onUserLoggedIn?.(user, {
47
- ip: req.ip ?? '',
48
- userAgent: req.headers['user-agent'] ?? '',
49
- sessionId,
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 = 'cache:' } = options;
4
- const createKey = (key) => `${keyPrefix}${key}`;
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(createKey(key));
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(createKey(key), JSON.stringify(value), { PX: expires });
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(createKey(key));
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
+ };
@@ -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
@@ -1,5 +1,6 @@
1
1
  export * from './auth.js';
2
2
  export * from './cache.js';
3
+ export * from './channels.js';
3
4
  export * from './cli.js';
4
5
  export * from './config.js';
5
6
  export * from './context.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /* c8 ignore next 100 */
2
2
  export * from './auth.js';
3
3
  export * from './cache.js';
4
+ export * from './channels.js';
4
5
  export * from './cli.js';
5
6
  export * from './config.js';
6
7
  export * from './context.js';
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 { before, route } from './middleware.js';
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?', before(), validateQuery({
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').action(cli.wrap(async () => {
50
- console.log(config.value);
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
  }
@@ -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>>(fn: (req: Request<Record<string, 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>;
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>;
@@ -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
- ((req, res, next) => {
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
@@ -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
- export declare function validateBody(schema: ValidationSchema): RequestHandler;
31
- export declare function validateQuery(schema: ValidationSchema): RequestHandler;
32
- export declare function validateParams(schema: ValidationSchema): RequestHandler;
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: ValidationSchema, options?: Options): ValidationResult;
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 = true, }) {
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: true,
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(), '_index.html'),
42
+ index: path.resolve(process.cwd(), 'index.html'),
43
43
  },
44
44
  },
45
45
  },
@@ -1,6 +1,6 @@
1
- export interface SsrMiddleWareOptions {
1
+ export interface SsrMiddlewareOptions<InitialData = unknown> {
2
2
  root?: string;
3
- getInitialData?: (req: string) => Promise<unknown>;
3
+ getInitialData?: (req: Request) => Promise<InitialData>;
4
4
  useDevServer?: boolean;
5
5
  }
6
- export declare function createSsrMiddleware({ root, getInitialData, useDevServer, }?: SsrMiddleWareOptions): Promise<import("compose-middleware").RequestHandler<unknown, unknown, void>>;
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
- export async function createSsrMiddleware({ root = process.cwd(), getInitialData = async () => ({}), useDevServer = config.value.VITE_USE_DEV_SERVER, } = {}) {
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, res, {
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({}, rendered.context, await readManifest());
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}">`);
@@ -1,12 +1,13 @@
1
- import { type Request, type Response } from 'express';
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: string) => Promise<unknown>;
7
+ getInitialData: (req: Request) => Promise<InitialData>;
8
+ publicUrl: URL;
8
9
  }
9
- export type EntryRenderFn = (req: Request, res: Response, options: SsrRenderOptions) => Promise<{
10
+ export type EntryRenderFn = (req: Request, options: SsrRenderOptions) => Promise<{
10
11
  html: string;
11
12
  context: any;
12
13
  }>;
@@ -1 +1 @@
1
- import {} from 'express';
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.49.4",
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
- "@devmoods/eslint-config": "^2.5.0",
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.5",
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": "^2.1.8",
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.7",
62
- "vitest": "^2.1.8",
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.48.0",
68
- "@sentry/profiling-node": "^8.48.0",
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.7",
73
+ "@types/jsonwebtoken": "^9.0.8",
73
74
  "@types/nodemailer": "^6.4.17",
74
- "@types/pg": "^8.11.10",
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.0.0",
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.9.16",
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"