@devmoods/express-extras 0.50.0 → 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.
@@ -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/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';
@@ -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
@@ -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,7 +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
  `,
52
- publicUrl: config.value.PUBLIC_URL,
55
+ publicUrl: new URL(config.value.PUBLIC_URL),
56
+ csrfToken: generateCsrfToken(req, res),
53
57
  });
54
58
  const readManifest = async () => {
55
59
  if (useDevServer) {
@@ -59,7 +63,14 @@ export async function createSsrMiddleware({ root = process.cwd(), getInitialData
59
63
  return getAssetLinks(manifestPath, 'src/entry-client.tsx');
60
64
  };
61
65
  let html = rendered.html;
62
- 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());
63
74
  const ssrPlaceholders = Object.fromEntries(Object.entries(context).map(([k, v]) => [`ssr-${k}`, v.toString()]));
64
75
  // Add nonce to all bare script tags
65
76
  html = html.replace(/<script>/g, `<script nonce="${nonce}">`);
@@ -1,13 +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>;
8
- publicUrl: string;
7
+ getInitialData: (req: Request) => Promise<InitialData>;
8
+ publicUrl: URL;
9
9
  }
10
- export type EntryRenderFn = (req: Request, res: Response, options: SsrRenderOptions) => Promise<{
10
+ export type EntryRenderFn = (req: Request, options: SsrRenderOptions) => Promise<{
11
11
  html: string;
12
12
  context: any;
13
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.50.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
+ "arktype": "^2.0.2",
47
48
  "@devmoods/eslint-config": "^2.6.0",
48
49
  "@types/koa-compose": "^3.2.8",
49
- "@types/node": "22.10.7",
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": "^3.0.2",
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,35 +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": "^3.0.2",
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.50.0",
68
- "@sentry/profiling-node": "^8.50.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
- "arktype": "^2.0.0",
80
80
  "bcrypt": "^5.1.1",
81
81
  "chalk": "^5.4.1",
82
- "commander": "^13.0.0",
82
+ "commander": "^13.1.0",
83
83
  "compose-middleware": "^5.0.1",
84
84
  "cookie-parser": "^1.4.7",
85
85
  "dotenv": "^16.4.7",
86
86
  "express": "^4.21.2",
87
87
  "helmet": "^8.0.0",
88
88
  "jsonwebtoken": "^9.0.2",
89
- "nodemailer": "^6.9.16",
89
+ "nodemailer": "^6.10.0",
90
90
  "pg": "^8.13.1",
91
91
  "redis": "^4.7.0",
92
92
  "sonda": "^0.7.1",
@@ -99,7 +99,8 @@
99
99
  "peerDependencies": {
100
100
  "@vitejs/plugin-react": ">=4.3.4",
101
101
  "faktory-worker": ">=4.5.1",
102
- "vite": ">=6.0.5"
102
+ "vite": ">=6.0.5",
103
+ "arktype": ">=2.0.2"
103
104
  },
104
105
  "engines": {
105
106
  "node": ">=20 <24"