@devmoods/express-extras 0.63.1 → 0.65.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.
Files changed (75) hide show
  1. package/README.md +0 -111
  2. package/dist/auth/emailAndPassword.js +2 -1
  3. package/dist/auth/google.d.ts +4 -4
  4. package/dist/auth/google.js +14 -11
  5. package/dist/auth/passwordless.js +2 -1
  6. package/dist/auth/token.js +6 -6
  7. package/dist/auth/types.d.ts +1 -1
  8. package/dist/auth.d.ts +1 -1
  9. package/dist/auth.js +17 -10
  10. package/dist/cache.d.ts +3 -0
  11. package/dist/cache.js +3 -2
  12. package/dist/channels.d.ts +9 -5
  13. package/dist/channels.js +115 -23
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +66 -66
  16. package/dist/config.js +35 -36
  17. package/dist/context.d.ts +5 -2
  18. package/dist/context.js +10 -9
  19. package/dist/csrf.js +6 -6
  20. package/dist/email.d.ts +1 -1
  21. package/dist/email.js +3 -3
  22. package/dist/errors.js +1 -1
  23. package/dist/faktory.d.ts +1 -1
  24. package/dist/faktory.js +5 -15
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +4 -3
  27. package/dist/listen.d.ts +5 -4
  28. package/dist/listen.js +47 -45
  29. package/dist/logger.d.ts +1 -0
  30. package/dist/logger.js +11 -5
  31. package/dist/manage.d.ts +4 -6
  32. package/dist/manage.js +41 -37
  33. package/dist/middleware.d.ts +3 -0
  34. package/dist/middleware.js +24 -7
  35. package/dist/rate-limit.d.ts +1 -0
  36. package/dist/rate-limit.js +25 -3
  37. package/dist/redis.d.ts +3 -0
  38. package/dist/redis.js +25 -2
  39. package/dist/sentry.js +7 -3
  40. package/dist/setupTests.global.d.ts +2 -0
  41. package/dist/setupTests.global.js +4 -0
  42. package/dist/setupTests.js +5 -1
  43. package/dist/slack.js +2 -2
  44. package/dist/utils.d.ts +2 -14
  45. package/dist/utils.js +2 -21
  46. package/dist/validate.js +7 -4
  47. package/dist/vite/__tests__/fixtures/src/entry-server.d.ts +5 -0
  48. package/dist/vite/__tests__/fixtures/src/entry-server.js +15 -0
  49. package/dist/vite/defineViteConfig.js +11 -1
  50. package/dist/vite/index.d.ts +1 -0
  51. package/dist/vite/index.js +3 -0
  52. package/dist/vite/ssr.d.ts +1 -0
  53. package/dist/vite/ssr.js +9 -4
  54. package/dist/vitest/index.d.ts +6 -0
  55. package/dist/vitest/index.js +25 -0
  56. package/dist/vitest/matchers.d.ts +11 -0
  57. package/dist/vitest/matchers.js +65 -0
  58. package/dist/vitest/testClient.d.ts +30 -0
  59. package/dist/vitest/testClient.js +98 -0
  60. package/dist/webapi.js +4 -0
  61. package/package.json +38 -43
  62. package/dist/__tests__/__migrations__/1_a.d.ts +0 -3
  63. package/dist/__tests__/__migrations__/1_a.js +0 -9
  64. package/dist/migrate.d.ts +0 -47
  65. package/dist/migrate.js +0 -255
  66. package/dist/postgres-ts-generator.d.ts +0 -11
  67. package/dist/postgres-ts-generator.js +0 -114
  68. package/dist/postgres.d.ts +0 -71
  69. package/dist/postgres.js +0 -261
  70. package/dist/sql.d.ts +0 -33
  71. package/dist/sql.js +0 -135
  72. package/dist/vite/vitest.d.ts +0 -1
  73. package/dist/vite/vitest.js +0 -1
  74. package/dist/vite.d.ts +0 -1
  75. package/dist/vite.js +0 -1
package/README.md CHANGED
@@ -1,112 +1 @@
1
1
  # @devmoods/express-extras
2
-
3
- > Express evolved: Type-safe routing, elegant transactions, and production-ready patterns for modern Node.js
4
-
5
- ## ⚠️ Express v5 ⚠️
6
-
7
- Due to [internal changes in the router](https://github.com/pillarjs/router/issues/122) used in Express v5 that breaks the ability to generate a full set of routes using the `routes` management command and to generate the OpenAPI spec, this library doesn't officially support Express v5. But in most cases if will work just fine as long as you don't rely on these features.
8
-
9
- ## Get ready
10
-
11
- _Extremely opinionated_ set of utilities for cranking out production-ready Node.js apps in a timely manner. We assume you are using PostgreSQL as the main database, Redis for data you don't want in Postgres and [Faktory](https://contribsys.com/faktory/) for background jobs.
12
-
13
- - Only works with [ECMAScript Modules (ESM)](https://nodejs.org/api/esm.html)
14
- - Thin layer on top of [Express](https://expressjs.com) with better support for `async`/`await` and type safety.
15
-
16
- ```typescript
17
- const router = createRouter();
18
-
19
- router.put(
20
- '/posts/:id',
21
- {
22
- request: type({ title: 'string' }),
23
- response: type({ id: 'number', title: 'string' }),
24
- },
25
- async (req) => {
26
- await delay(1000);
27
- return {
28
- id: req.params.id,
29
- ...req.body,
30
- };
31
- },
32
- );
33
- ```
34
-
35
- - Clean graceful shutdown procedures and built-in health checks. Runs perfectly in Kubernetes.
36
- - PostgreSQL with transactions and migrations
37
-
38
- ```bash
39
- bin/dx manage migrate up
40
- ```
41
-
42
- ```typescript
43
- await postgres.transaction(() => {
44
- const post = await postgres.get<{ title: string }>(
45
- sql`SELECT title FROM posts WHERE id = ${id} FOR UPDATE`,
46
- );
47
-
48
- await postgres.query(
49
- sql`UPDATE posts SET ${sql.spreadUpdate({ title: post.title.split('').reverse().join('') })} WHERE id = ${id}`,
50
- );
51
-
52
- try {
53
- // Nested transactions with savepoints
54
- await postgres.transaction(() => {
55
- })
56
- } catch () {
57
- }
58
-
59
- throw new Error('please rollback');
60
- });
61
- ```
62
-
63
- - CSRF protection
64
- - Built in auth backends! Email+password, passwordless, easy to extend with more
65
- - OpenAPI v3 generator
66
- - Redis
67
- - Slack/Discord client for sending operational notifications
68
- - CLI commands inspired by Django's management commands
69
-
70
- ```bash
71
- bin/dx manage --help
72
- ```
73
-
74
- ```typescript
75
- registerManagementCommand((command, cli) => {
76
- command('hello').action(() => {
77
- cli.writeLine('Hello World');
78
- });
79
- });
80
- ```
81
-
82
- - Delayed/async jobs with [Faktory](https://contribsys.com/faktory/)
83
- - Validation of HTTP requests using [Standard Schema](https://github.com/standard-schema/standard-schema), including [`jsonSchema()`](https://json-schema.org/) and [arktype](https://arktype.io/).
84
- - Logging helpers to write well-formatted contextual JSON logs to `stdout`
85
-
86
- ```typescript
87
- const logger = getLogger();
88
-
89
- logger.info('hello world', { foo: 'bar', baz: 10 });
90
- // {"level":"info","hostname":"machine.local","pid":43149,"name":"default","message":"hello world","foo":"bar","baz":10,"time":"2024-01-02T17:25:28.610Z","v":1}
91
- ```
92
-
93
- - Request local context using [AsyncLocalStorage](https://nodejs.org/api/async_context.html) (e.g. request ids)
94
- - [RFC 7807 Problem Details for HTTP APIs](https://tools.ietf.org/html/rfc7807) by default
95
- - Uses [Sentry](https://sentry.io/) for external error monitoring
96
- - [The Twelve-Factor App](https://12factor.net/)
97
-
98
- ## Goals
99
-
100
- - Time-to-market and top-notch developer experience.
101
-
102
- ## Non-goals
103
-
104
- - Support for multiple different database vendors and job processing systems.
105
-
106
- ## Example
107
-
108
- > See the [example/](./example) folder for a complete example.
109
-
110
- ## Configure
111
-
112
- Put environment variables in `.env` at the application will automatically read those on startup. For test configuration in unit and integrations tests, use `.env.test`.
@@ -1,3 +1,4 @@
1
+ import { startTimer } from '@devmoods/observability';
1
2
  import argon2 from 'argon2';
2
3
  import bcrypt from 'bcrypt';
3
4
  import {} from 'express';
@@ -5,7 +6,6 @@ import { createTokenResponse, tokenSchema } from './token.js';
5
6
  import {} from './types.js';
6
7
  import { Unauthorized } from '../errors.js';
7
8
  import { getLogger } from '../logger.js';
8
- import { startTimer } from '../utils.js';
9
9
  import { jsonSchema } from '../validate.js';
10
10
  const passwordSchema = { type: 'string', minLength: 4 };
11
11
  const emailSchema = { type: 'string', minLength: 4 };
@@ -57,6 +57,7 @@ export function EmailAndPasswordAuthProvider(options) {
57
57
  }
58
58
  context.setCurrentUser(user);
59
59
  await options.onPasswordChanged?.(user, await hashPassword(password), {
60
+ /* c8 ignore next 2 */
60
61
  ip: req.ip ?? '',
61
62
  userAgent: req.headers['user-agent'] ?? '',
62
63
  });
@@ -4,10 +4,10 @@ interface GoogleProfile {
4
4
  fullName: string;
5
5
  }
6
6
  interface GoogleAuthProviderOptions<UserModel = AnyUser> {
7
- clientId: string;
8
- clientSecret: string;
9
- redirectUri: string;
7
+ clientId?: string;
8
+ clientSecret?: string;
9
+ redirectUri?: string;
10
10
  getOrCreateUserFromUserIdentifier(userIdentifier: string, profile: GoogleProfile): Promise<UserModel>;
11
11
  }
12
- export declare function GoogleAuthProvider<UserModel extends AnyUser>(options: GoogleAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
12
+ export declare function GoogleAuthProvider<UserModel extends AnyUser>({ clientId, clientSecret, redirectUri, getOrCreateUserFromUserIdentifier, }: GoogleAuthProviderOptions<UserModel>): AuthProvider<UserModel>;
13
13
  export {};
@@ -1,3 +1,4 @@
1
+ import { config } from '@devmoods/config';
1
2
  import { type } from 'arktype';
2
3
  import {} from 'express';
3
4
  import { OAuth2Client } from 'google-auth-library';
@@ -6,11 +7,11 @@ import { getLogger } from '../logger.js';
6
7
  import { createTokenResponse } from './token.js';
7
8
  import {} from './types.js';
8
9
  const logger = getLogger('auth');
9
- export function GoogleAuthProvider(options) {
10
+ export function GoogleAuthProvider({ clientId = config.GOOGLE_AUTH_CLIENT_ID, clientSecret = config.GOOGLE_AUTH_CLIENT_SECRET, redirectUri = `${config.PUBLIC_URL}/api/v1/auth/google/callback`, getOrCreateUserFromUserIdentifier, }) {
10
11
  const client = new OAuth2Client({
11
- clientId: options.clientId,
12
- clientSecret: options.clientSecret,
13
- redirectUri: options.redirectUri,
12
+ clientId,
13
+ clientSecret,
14
+ redirectUri,
14
15
  });
15
16
  const verifyAuthorizationCode = async (inputCode) => {
16
17
  try {
@@ -25,7 +26,7 @@ export function GoogleAuthProvider(options) {
25
26
  const verifyIdToken = async (token) => {
26
27
  const ticket = await client.verifyIdToken({
27
28
  idToken: token,
28
- audience: options.clientId,
29
+ audience: clientId,
29
30
  });
30
31
  const payload = ticket.getPayload();
31
32
  if (!payload) {
@@ -36,12 +37,11 @@ export function GoogleAuthProvider(options) {
36
37
  const requiredScopes = ['openid', 'email', 'profile'];
37
38
  return {
38
39
  setupRoutes(router, context) {
39
- const googleUser = { id: 'google' };
40
40
  router.get('/google/login', {}, async (req, res) => {
41
- const state = await context.createTemporaryToken(googleUser);
41
+ const state = await context.createTemporaryToken(null);
42
42
  const params = new URLSearchParams();
43
- params.set('client_id', options.clientId);
44
- params.set('redirect_uri', options.redirectUri);
43
+ params.set('client_id', clientId);
44
+ params.set('redirect_uri', redirectUri);
45
45
  params.set('response_type', 'code');
46
46
  params.set('scope', requiredScopes.join(' '));
47
47
  params.set('access_type', 'offline');
@@ -69,7 +69,7 @@ export function GoogleAuthProvider(options) {
69
69
  throw BadRequest(`Missing required scopes: ${requiredScopes.join(', ')}`);
70
70
  }
71
71
  try {
72
- await context.verifyTemporaryToken(state, googleUser, true);
72
+ await context.verifyTemporaryToken(state, null, true);
73
73
  }
74
74
  catch (e) {
75
75
  logger.error(e);
@@ -81,7 +81,10 @@ export function GoogleAuthProvider(options) {
81
81
  }
82
82
  const payload = await verifyIdToken(idToken);
83
83
  logger.info('google payload', { payload });
84
- const user = await options.getOrCreateUserFromUserIdentifier(payload?.sub, { email: payload.email, fullName: payload.name });
84
+ const user = await getOrCreateUserFromUserIdentifier(payload?.sub, {
85
+ email: payload.email,
86
+ fullName: payload.name,
87
+ });
85
88
  const accessToken = await context.login(req, res, user);
86
89
  logger.info('google-login', { email: user.email });
87
90
  if (req.accepts('html')) {
@@ -1,8 +1,8 @@
1
+ import { DoesNotExistError } from '@devmoods/postgres';
1
2
  import {} from 'express';
2
3
  import { createTokenResponse } from './token.js';
3
4
  import {} from './types.js';
4
5
  import { getLogger } from '../logger.js';
5
- import { DoesNotExistError } from '../postgres.js';
6
6
  import { jsonSchema } from '../validate.js';
7
7
  const logger = getLogger('auth');
8
8
  export function PasswordlessAuthProvider(options) {
@@ -13,6 +13,7 @@ export function PasswordlessAuthProvider(options) {
13
13
  catch (error) {
14
14
  if (error instanceof DoesNotExistError) {
15
15
  return null;
16
+ /* c8 ignore next 3 */
16
17
  }
17
18
  throw error;
18
19
  }
@@ -1,8 +1,8 @@
1
+ import { config } from '@devmoods/config';
1
2
  import {} from 'express';
2
3
  import jwt from 'jsonwebtoken';
3
4
  import { v4 } from 'uuid';
4
5
  import { InvalidToken } from './errors.js';
5
- import { config } from '../config.js';
6
6
  import {} from '../validate.js';
7
7
  const ACCESS_TOKEN_COOKIE_NAME = 'access_token';
8
8
  export const tokenSchema = {
@@ -24,8 +24,8 @@ export function createTokenResponse(accessToken) {
24
24
  /**
25
25
  * Returns a signed JWT token.
26
26
  */
27
- export function createAccessToken(payload, version = '1', secret = config.value.AUTH_SECRET) {
28
- const expiresIn = config.value.AUTH_TOKEN_EXPIRES_IN;
27
+ export function createAccessToken(payload, version = '1', secret = config.AUTH_JWT_SECRET) {
28
+ const expiresIn = config.AUTH_TOKEN_EXPIRES;
29
29
  const token = jwt.sign({ v: version, ...payload }, secret, {
30
30
  expiresIn,
31
31
  });
@@ -34,7 +34,7 @@ export function createAccessToken(payload, version = '1', secret = config.value.
34
34
  /**
35
35
  * Decode the given JWT token.
36
36
  */
37
- export function decodeAccessToken(token, secret = config.value.AUTH_SECRET) {
37
+ export function decodeAccessToken(token, secret = config.AUTH_JWT_SECRET) {
38
38
  try {
39
39
  const decoded = jwt.verify(token.trim(), secret);
40
40
  /* c8 ignore next 3 */
@@ -95,7 +95,7 @@ export function extractAccessTokenFromRequest(req) {
95
95
  * If the provided token is `null` the cookie will be cleared.
96
96
  */
97
97
  export function setAccessTokenCookie(res, token) {
98
- const sameSite = (config.value.AUTH_COOKIE_SAME_SITE || 'strict');
98
+ const sameSite = config.AUTH_COOKIE_SAME_SITE;
99
99
  if (token == null) {
100
100
  res.cookie(ACCESS_TOKEN_COOKIE_NAME, v4(), {
101
101
  expires: new Date(1970, 0, 1),
@@ -105,7 +105,7 @@ export function setAccessTokenCookie(res, token) {
105
105
  }
106
106
  else {
107
107
  res.cookie(ACCESS_TOKEN_COOKIE_NAME, token, {
108
- expires: new Date(Date.now() + config.value.AUTH_TOKEN_EXPIRES_IN * 1000),
108
+ expires: new Date(Date.now() + config.AUTH_TOKEN_EXPIRES * 1000),
109
109
  httpOnly: true,
110
110
  sameSite,
111
111
  secure: process.env.NODE_ENV === 'production',
@@ -35,7 +35,7 @@ export interface AuthProviderContext<UserModel extends AnyUser> {
35
35
  filterUser: AuthOptions<UserModel>['filterUser'];
36
36
  tokenStorage: AuthOptions<UserModel>['tokenStorage'];
37
37
  setCurrentUser: (user: UserModel) => void;
38
- createTemporaryToken(user: UserModel): Promise<string>;
38
+ createTemporaryToken(user: UserModel | null): Promise<string>;
39
39
  verifyTemporaryToken(token: string, user: UserModel | null, consume?: boolean): Promise<string>;
40
40
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
41
41
  createOtp(user: UserModel): Promise<string>;
package/dist/auth.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface CreateAuth<UserModel extends AnyUser> {
13
13
  before: () => RequestHandler;
14
14
  allow: (fn?: AuthIsAllowedFn<UserModel>) => RequestHandler;
15
15
  isAuthorized: (fn?: (user: UserModel) => boolean) => RequestHandler;
16
- createTemporaryToken(user: UserModel): Promise<string>;
16
+ createTemporaryToken(user: UserModel | null): Promise<string>;
17
17
  verifyTemporaryToken(token: string, user: UserModel | null): Promise<string>;
18
18
  decodeTemporaryToken(token: string): DecodedTemporaryToken;
19
19
  createOtp(user: UserModel): Promise<string>;
package/dist/auth.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import * as crypto from 'node:crypto';
2
+ import { config } from '@devmoods/config';
2
3
  import {} from 'express';
3
4
  import { InvalidToken, PermissionDenied } from './auth/errors.js';
4
5
  import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
5
6
  import { TokenStorage, } from './auth/types.js';
6
- import { config } from './config.js';
7
- import { setContext, useContext } from './context.js';
7
+ import { setRequestContext, useRequestContext } from './context.js';
8
8
  import { Forbidden, NotFound, Unauthorized } from './errors.js';
9
9
  import { getLogger } from './logger.js';
10
10
  import {} from './redis.js';
@@ -19,7 +19,7 @@ export * from './auth/google.js';
19
19
  export class RedisTokenStorage extends TokenStorage {
20
20
  redis;
21
21
  keyPrefix;
22
- constructor(redis, keyPrefix = `${config.value.REDIS_KEY_PREFIX}auth:`) {
22
+ constructor(redis, keyPrefix = `${config.REDIS_KEY_PREFIX}auth:`) {
23
23
  super();
24
24
  this.redis = redis;
25
25
  this.keyPrefix = keyPrefix;
@@ -38,8 +38,8 @@ export class RedisTokenStorage extends TokenStorage {
38
38
  }
39
39
  const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
40
40
  export function createAuth({ getUserById, filterUser, tokenStorage, providers, loginUrl = '/', onUserLoggedIn, sessionTokenVersion = '1', }) {
41
- const useCurrentUser = () => useContext('currentUser');
42
- const setCurrentUser = (user) => setContext('currentUser', user);
41
+ const useCurrentUser = () => useRequestContext('currentUser');
42
+ const setCurrentUser = (user) => setRequestContext('currentUser', user);
43
43
  const login = async (req, res, user, isRefresh = false) => {
44
44
  const accessToken = createAccessToken({ uid: user.id }, sessionTokenVersion);
45
45
  const sessionId = sha256(accessToken.token);
@@ -63,7 +63,12 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
63
63
  }
64
64
  };
65
65
  // Make timing attacks harder
66
- const jitter = (duration = 0) => new Promise((resolve) => void setTimeout(resolve, Math.max((0.5 - duration) * 1000, 200)));
66
+ const jitter = (duration = 0) => {
67
+ if (process.env.NODE_ENV === 'test') {
68
+ return Promise.resolve();
69
+ }
70
+ return new Promise((resolve) => void setTimeout(resolve, Math.max((0.5 - duration) * 1000, 200)));
71
+ };
67
72
  /**
68
73
  * API for auth providers to use
69
74
  */
@@ -224,7 +229,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
224
229
  }
225
230
  async function createOtp(user) {
226
231
  const otp = crypto.randomInt(100000, 999999).toString();
227
- await tokenStorage.set(otp, String(user.id), config.value.AUTH_OTP_EXPIRES_IN);
232
+ await tokenStorage.set(otp, String(user.id), config.AUTH_OTP_EXPIRES);
228
233
  return otp;
229
234
  }
230
235
  async function verifyOtp(otp, user, consume = true) {
@@ -232,6 +237,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
232
237
  if (!storedUid) {
233
238
  throw new InvalidToken('Invalid OTP');
234
239
  }
240
+ /* c8 ignore next 3 */
235
241
  if (user && storedUid !== user.id) {
236
242
  throw new InvalidToken('Invalid OTP');
237
243
  }
@@ -250,7 +256,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
250
256
  const randomBytes = crypto.randomBytes(32).toString('hex');
251
257
  const payload = { v: '1', uid: uid, r: randomBytes };
252
258
  const signature = crypto
253
- .createHmac('sha256', config.value.AUTH_SECRET)
259
+ .createHmac('sha256', config.AUTH_JWT_SECRET)
254
260
  .update(JSON.stringify(payload))
255
261
  .digest('base64')
256
262
  .slice(0, 12);
@@ -258,7 +264,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
258
264
  throw new Error('User ID is required to create a token');
259
265
  }
260
266
  const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
261
- await tokenStorage.set(temporaryToken, String(uid), config.value.AUTH_TEMP_TOKEN_EXPIRES_IN);
267
+ await tokenStorage.set(temporaryToken, String(uid), config.AUTH_TEMP_TOKEN_EXPIRES);
262
268
  return temporaryToken;
263
269
  }
264
270
  catch (error) {
@@ -272,10 +278,11 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
272
278
  async function verifyTemporaryToken(token, user, consume = true) {
273
279
  const decoded = decodeTemporaryToken(token);
274
280
  const expectedSignature = crypto
275
- .createHmac('sha256', config.value.AUTH_SECRET)
281
+ .createHmac('sha256', config.AUTH_JWT_SECRET)
276
282
  .update(JSON.stringify(decoded.payload))
277
283
  .digest('base64')
278
284
  .slice(0, 12);
285
+ /* c8 ignore next 3 */
279
286
  if (expectedSignature !== decoded.signature) {
280
287
  throw new InvalidToken('Invalid signature');
281
288
  }
package/dist/cache.d.ts CHANGED
@@ -23,4 +23,7 @@ export declare function createCache(adapter: CacheAdapter): {
23
23
  (...args: Arguments): Promise<T>;
24
24
  clear(...args: Arguments): Promise<void>;
25
25
  };
26
+ hashKey: typeof hashKey;
26
27
  };
28
+ declare function hashKey(obj: unknown): string;
29
+ export {};
package/dist/cache.js CHANGED
@@ -1,7 +1,7 @@
1
- import { config } from './config.js';
1
+ import { config } from '@devmoods/config';
2
2
  import {} from './redis.js';
3
3
  export function createRedisCacheAdapter(redis, options = {}) {
4
- const { keyPrefix = `${config.value.REDIS_KEY_PREFIX}cache:` } = options;
4
+ const { keyPrefix = `${config.REDIS_KEY_PREFIX}cache:` } = options;
5
5
  const withKeyPrefix = (key) => `${keyPrefix}${key}`;
6
6
  return {
7
7
  async get(key) {
@@ -97,6 +97,7 @@ export function createCache(adapter) {
97
97
  clear: adapter.clear.bind(adapter),
98
98
  wrap: cached,
99
99
  decorate: cachedFn,
100
+ hashKey,
100
101
  };
101
102
  }
102
103
  function hashKey(obj) {
@@ -1,9 +1,13 @@
1
+ import { type StandardSchemaV1 } from '@standard-schema/spec';
1
2
  import { type Request } from 'express';
2
3
  import { type RedisClient } from './redis.js';
3
4
  import { type ExtractParams, type TypedRequestHandler } from './router.js';
4
- export declare function createEventStream<Channels extends string>(redis: RedisClient): {
5
- sse: <Path extends string>({ channels, }: {
6
- channels: (req: Request) => string[] | Promise<string[]>;
7
- }) => TypedRequestHandler<ExtractParams<Path>, any, any, any>;
8
- publish: (channel: Channels, message: unknown) => Promise<number>;
5
+ export interface EventStreamSseOptions<Schema extends StandardSchemaV1> {
6
+ channels: (req: Request) => string[] | Promise<string[]>;
7
+ getIdFromMessage?: (message: StandardSchemaV1.InferOutput<Schema>) => string | undefined;
8
+ }
9
+ export declare function createEventStream<Channels extends string, Schema extends StandardSchemaV1>(redis: RedisClient, schema: Schema): {
10
+ sse: <const Path extends string>({ channels, getIdFromMessage, }: EventStreamSseOptions<Schema>) => TypedRequestHandler<ExtractParams<Path>, any, any, any>;
11
+ publish: (channel: Channels, message: StandardSchemaV1.InferOutput<Schema>) => Promise<number>;
12
+ close: () => Promise<void>;
9
13
  };
package/dist/channels.js CHANGED
@@ -1,63 +1,149 @@
1
+ import { config } from '@devmoods/config';
2
+ import {} from '@standard-schema/spec';
1
3
  import {} from 'express';
2
- import { config } from './config.js';
3
4
  import { requestContext } from './context.js';
4
5
  import { getLogger } from './logger.js';
5
6
  import {} from './redis.js';
6
7
  import {} from './router.js';
7
8
  const logger = getLogger();
8
- export function createEventStream(redis) {
9
- const keyPrefix = `${config.value.REDIS_KEY_PREFIX}events:`;
9
+ const defaultGetIdFromMessage = (message) => {
10
+ if (typeof message === 'object' && message !== null && 'id' in message) {
11
+ return `${message.id}`;
12
+ }
13
+ return undefined;
14
+ };
15
+ function writeChunk(res, chunk, state) {
16
+ // If we're already draining, just enqueue.
17
+ if (state.draining) {
18
+ if (state.queue.length >= state.maxQueue) {
19
+ // Drop the oldest chunk to keep memory bounded.
20
+ state.queue.shift();
21
+ }
22
+ state.queue.push(chunk);
23
+ return;
24
+ }
25
+ if (res.write(chunk)) {
26
+ return;
27
+ }
28
+ // Buffer is full — start draining
29
+ state.draining = true;
30
+ const flush = () => {
31
+ while (state.queue.length) {
32
+ const next = state.queue.shift();
33
+ if (!res.write(next)) {
34
+ state.draining = true;
35
+ res.once('drain', flush);
36
+ return;
37
+ }
38
+ }
39
+ state.draining = false;
40
+ };
41
+ res.once('drain', flush);
42
+ }
43
+ export function createEventStream(redis, schema) {
44
+ const keyPrefix = `${config.REDIS_KEY_PREFIX}events:`;
10
45
  const withKeyPrefix = (key) => `${keyPrefix}${key}`;
11
- const sse = ({ channels, }) => {
46
+ const subscribers = new Set();
47
+ const sse = ({ channels, getIdFromMessage = defaultGetIdFromMessage, }) => {
12
48
  return async (req, res, next) => {
13
49
  try {
14
- const headers = {
15
- 'Content-Type': 'text/event-stream',
16
- 'Cache-Control': 'no-cache',
17
- Connection: 'keep-alive',
18
- };
19
- res.writeHead(200, headers);
20
- res.write('retry: 15000\n');
50
+ const subscriber = redis.duplicate();
51
+ /* c8 ignore next 3 */
52
+ subscriber.on('error', (error) => {
53
+ logger.error(error);
54
+ });
55
+ await subscriber.connect();
21
56
  const heartbeat = setInterval(() => {
22
57
  try {
23
58
  if (!res.writableEnded) {
24
- res.write('event: heartbeat\n');
25
- res.write('data: ping\n\n');
59
+ writeChunk(res, 'event: heartbeat\n', state);
60
+ writeChunk(res, 'data: ping\n\n', state);
26
61
  }
27
62
  }
28
63
  catch (error) {
29
64
  logger.error(error);
30
65
  }
31
66
  }, 15000);
67
+ const state = {
68
+ draining: false,
69
+ queue: [],
70
+ maxQueue: 1000,
71
+ };
32
72
  const store = requestContext.getStore();
33
73
  const listener = (value) => {
34
74
  try {
35
75
  requestContext.run(store, () => {
36
76
  if (!res.writableEnded) {
37
- res.write('event: data\n');
38
- res.write(`data: ${JSON.stringify(value)}\n\n`);
77
+ const id = getIdFromMessage?.(value);
78
+ if (id) {
79
+ writeChunk(res, `id: ${id}\n`, state);
80
+ }
81
+ writeChunk(res, 'event: data\n', state);
82
+ writeChunk(res, `data: ${JSON.stringify(value)}\n\n`, state);
39
83
  }
40
84
  });
85
+ /* c8 ignore next 3 */
41
86
  }
42
87
  catch (error) {
43
88
  logger.error(error);
44
89
  }
45
90
  };
46
- const subscriber = redis.duplicate();
47
- await subscriber.connect();
48
91
  const _channels = await channels(req);
49
92
  await subscriber.subscribe(_channels.map(withKeyPrefix), (message) => {
50
- listener(JSON.parse(message));
93
+ let parsedMessage;
94
+ try {
95
+ parsedMessage = JSON.parse(message);
96
+ /* c8 ignore next 3 */
97
+ }
98
+ catch (error) {
99
+ logger.error(error);
100
+ }
101
+ if (parsedMessage) {
102
+ listener(parsedMessage);
103
+ }
51
104
  });
52
105
  logger.info('event stream subscribe', { channels: _channels });
53
- res.on('close', () => {
106
+ const headers = {
107
+ 'Content-Type': 'text/event-stream',
108
+ 'Cache-Control': 'no-cache',
109
+ Connection: 'keep-alive',
110
+ 'x-accel-buffering': 'no',
111
+ };
112
+ res.writeHead(200, headers);
113
+ writeChunk(res, 'retry: 10000\n', state);
114
+ let closed = false;
115
+ const close = () => {
116
+ if (closed) {
117
+ return;
118
+ }
119
+ closed = true;
54
120
  requestContext.run(store, () => {
55
121
  logger.info('event stream close');
56
122
  clearInterval(heartbeat);
57
- subscriber.quit();
58
- res.end();
123
+ subscriber.close();
59
124
  });
60
- });
125
+ };
126
+ const gracefulClose = () => {
127
+ if (closed) {
128
+ return;
129
+ }
130
+ if (!res.writableEnded) {
131
+ writeChunk(res, 'event: close\n', state);
132
+ writeChunk(res, 'data: shutdown\n\n', state);
133
+ res.end();
134
+ }
135
+ close();
136
+ };
137
+ subscribers.add(gracefulClose);
138
+ const cleanup = () => {
139
+ subscribers.delete(gracefulClose);
140
+ close();
141
+ };
142
+ res.on('close', cleanup);
143
+ res.on('finish', cleanup);
144
+ req.on('aborted', cleanup);
145
+ req.socket.on('error', cleanup);
146
+ /* c8 ignore next 3 */
61
147
  }
62
148
  catch (error) {
63
149
  next(error);
@@ -67,5 +153,11 @@ export function createEventStream(redis) {
67
153
  const publish = (channel, message) => {
68
154
  return redis.publish(withKeyPrefix(channel), JSON.stringify(message));
69
155
  };
70
- return { sse, publish };
156
+ const close = async () => {
157
+ for (const closeSubscriber of subscribers) {
158
+ closeSubscriber();
159
+ }
160
+ subscribers.clear();
161
+ };
162
+ return { sse, publish, close };
71
163
  }
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { stdin, stdout, stderr } from 'node:process';
2
2
  import * as readline from 'node:readline';
3
3
  import {} from 'node:tty';
4
+ import { startTimer } from '@devmoods/observability';
4
5
  import chalk from 'chalk';
5
- import { startTimer } from './utils.js';
6
6
  export class CLI {
7
7
  options;
8
8
  constructor(options = {}) {