@devmoods/express-extras 0.63.0 → 0.64.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.js CHANGED
@@ -197,7 +197,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
197
197
  try {
198
198
  const user = useCurrentUser();
199
199
  if (!user) {
200
- throw new PermissionDenied('User not logged in');
200
+ throw new InvalidToken('User not logged in');
201
201
  }
202
202
  if (isAuthorized && !(await isAuthorized(user))) {
203
203
  throw new PermissionDenied('Permission denied');
@@ -1,9 +1,12 @@
1
1
  import { type Request } from 'express';
2
2
  import { type RedisClient } from './redis.js';
3
3
  import { type ExtractParams, type TypedRequestHandler } from './router.js';
4
+ export interface EventStreamSseOptions {
5
+ channels: (req: Request) => string[] | Promise<string[]>;
6
+ getIdFromMessage?: (message: unknown) => string | undefined;
7
+ }
4
8
  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>;
9
+ sse: <Path extends string>({ channels, getIdFromMessage, }: EventStreamSseOptions) => TypedRequestHandler<ExtractParams<Path>, any, any, any>;
8
10
  publish: (channel: Channels, message: unknown) => Promise<number>;
11
+ close: () => Promise<void>;
9
12
  };
package/dist/channels.js CHANGED
@@ -5,37 +5,79 @@ import { getLogger } from './logger.js';
5
5
  import {} from './redis.js';
6
6
  import {} from './router.js';
7
7
  const logger = getLogger();
8
+ const defaultGetIdFromMessage = (message) => {
9
+ if (typeof message === 'object' && message !== null && 'id' in message) {
10
+ return `${message.id}`;
11
+ }
12
+ return undefined;
13
+ };
14
+ function writeChunk(res, chunk, state) {
15
+ // If we're already draining, just enqueue.
16
+ if (state.draining) {
17
+ if (state.queue.length >= state.maxQueue) {
18
+ // Drop the oldest chunk to keep memory bounded.
19
+ state.queue.shift();
20
+ }
21
+ state.queue.push(chunk);
22
+ return;
23
+ }
24
+ if (res.write(chunk)) {
25
+ return;
26
+ }
27
+ // Buffer is full — start draining
28
+ state.draining = true;
29
+ const flush = () => {
30
+ while (state.queue.length) {
31
+ const next = state.queue.shift();
32
+ if (!res.write(next)) {
33
+ state.draining = true;
34
+ res.once('drain', flush);
35
+ return;
36
+ }
37
+ }
38
+ state.draining = false;
39
+ };
40
+ res.once('drain', flush);
41
+ }
8
42
  export function createEventStream(redis) {
9
43
  const keyPrefix = `${config.value.REDIS_KEY_PREFIX}events:`;
10
44
  const withKeyPrefix = (key) => `${keyPrefix}${key}`;
11
- const sse = ({ channels, }) => {
45
+ const subscribers = new Set();
46
+ const sse = ({ channels, getIdFromMessage = defaultGetIdFromMessage, }) => {
12
47
  return async (req, res, next) => {
13
48
  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');
49
+ const subscriber = redis.duplicate();
50
+ subscriber.on('error', (error) => {
51
+ logger.error(error);
52
+ });
53
+ await subscriber.connect();
21
54
  const heartbeat = setInterval(() => {
22
55
  try {
23
56
  if (!res.writableEnded) {
24
- res.write('event: heartbeat\n');
25
- res.write('data: ping\n\n');
57
+ writeChunk(res, 'event: heartbeat\n', state);
58
+ writeChunk(res, 'data: ping\n\n', state);
26
59
  }
27
60
  }
28
61
  catch (error) {
29
62
  logger.error(error);
30
63
  }
31
64
  }, 15000);
65
+ const state = {
66
+ draining: false,
67
+ queue: [],
68
+ maxQueue: 1000,
69
+ };
32
70
  const store = requestContext.getStore();
33
71
  const listener = (value) => {
34
72
  try {
35
73
  requestContext.run(store, () => {
36
74
  if (!res.writableEnded) {
37
- res.write('event: data\n');
38
- res.write(`data: ${JSON.stringify(value)}\n\n`);
75
+ const id = getIdFromMessage?.(value);
76
+ if (id) {
77
+ writeChunk(res, `id: ${id}\n`, state);
78
+ }
79
+ writeChunk(res, 'event: data\n', state);
80
+ writeChunk(res, `data: ${JSON.stringify(value)}\n\n`, state);
39
81
  }
40
82
  });
41
83
  }
@@ -43,21 +85,60 @@ export function createEventStream(redis) {
43
85
  logger.error(error);
44
86
  }
45
87
  };
46
- const subscriber = redis.duplicate();
47
- await subscriber.connect();
48
88
  const _channels = await channels(req);
49
89
  await subscriber.subscribe(_channels.map(withKeyPrefix), (message) => {
50
- listener(JSON.parse(message));
90
+ let parsedMessage;
91
+ try {
92
+ parsedMessage = JSON.parse(message);
93
+ }
94
+ catch (error) {
95
+ logger.error(error);
96
+ }
97
+ if (parsedMessage) {
98
+ listener(parsedMessage);
99
+ }
51
100
  });
52
101
  logger.info('event stream subscribe', { channels: _channels });
53
- res.on('close', () => {
102
+ const headers = {
103
+ 'Content-Type': 'text/event-stream',
104
+ 'Cache-Control': 'no-cache, no-transform',
105
+ Connection: 'keep-alive',
106
+ 'x-accel-buffering': 'no',
107
+ };
108
+ res.writeHead(200, headers);
109
+ writeChunk(res, 'retry: 10000\n', state);
110
+ let closed = false;
111
+ const close = () => {
112
+ if (closed) {
113
+ return;
114
+ }
115
+ closed = true;
54
116
  requestContext.run(store, () => {
55
117
  logger.info('event stream close');
56
118
  clearInterval(heartbeat);
57
- subscriber.quit();
58
- res.end();
119
+ subscriber.close();
59
120
  });
60
- });
121
+ };
122
+ const gracefulClose = () => {
123
+ if (closed) {
124
+ return;
125
+ }
126
+ if (!res.writableEnded) {
127
+ writeChunk(res, 'event: close\n', state);
128
+ writeChunk(res, 'data: shutdown\n\n', state);
129
+ res.end();
130
+ }
131
+ close();
132
+ };
133
+ subscribers.add(gracefulClose);
134
+ const cleanup = () => {
135
+ subscribers.delete(gracefulClose);
136
+ close();
137
+ };
138
+ res.on('close', cleanup);
139
+ res.on('finish', cleanup);
140
+ req.on('aborted', cleanup);
141
+ req.socket.on('error', cleanup);
61
142
  }
62
143
  catch (error) {
63
144
  next(error);
@@ -67,5 +148,11 @@ export function createEventStream(redis) {
67
148
  const publish = (channel, message) => {
68
149
  return redis.publish(withKeyPrefix(channel), JSON.stringify(message));
69
150
  };
70
- return { sse, publish };
151
+ const close = async () => {
152
+ for (const closeSubscriber of subscribers) {
153
+ closeSubscriber();
154
+ }
155
+ subscribers.clear();
156
+ };
157
+ return { sse, publish, close };
71
158
  }
package/dist/listen.d.ts CHANGED
@@ -7,6 +7,7 @@ interface StoppableServer extends Server {
7
7
  export declare function stoppable(server: Server, grace?: number): StoppableServer;
8
8
  interface ExpressServerOptions {
9
9
  onShutdown?(): Promise<void> | void;
10
+ beforeShutdown?(): Promise<void> | void;
10
11
  healthCheck?(type?: 'live' | 'ready'): Promise<unknown>;
11
12
  }
12
13
  export declare function createExpressServer(app: Application, options?: ExpressServerOptions): {
package/dist/listen.js CHANGED
@@ -87,9 +87,13 @@ export function createExpressServer(app, options = {}) {
87
87
  try {
88
88
  logger.info(`app shutting down due to ${signal}`);
89
89
  await delay(READINESS_PERIOD + 1000);
90
- await server.stop();
90
+ await options.beforeShutdown?.();
91
+ const gracefully = await server.stop();
91
92
  await options.onShutdown?.();
92
- logger.info('app shut down', { duration: timer.getDuration() });
93
+ logger.info('app shut down', {
94
+ duration: timer.getDuration(),
95
+ gracefully,
96
+ });
93
97
  process.exit(0);
94
98
  }
95
99
  catch (error) {
package/dist/redis.d.ts CHANGED
@@ -5,3 +5,6 @@ export interface RedisOptions {
5
5
  }
6
6
  export type RedisClient = ReturnType<typeof createClient>;
7
7
  export declare function createRedis({ url, onError, }?: RedisOptions): RedisClient;
8
+ export declare function connectRedis(redis: RedisClient, shouldContinue?: boolean): Promise<void>;
9
+ export declare function stopRedis(redis: RedisClient, timeout?: number): Promise<void>;
10
+ export declare function pingRedis(redis: RedisClient): Promise<string>;
package/dist/redis.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createClient } from 'redis';
2
2
  import { config } from './config.js';
3
+ import { withTimeout } from './utils.js';
3
4
  export function createRedis({ url = config.value.REDIS_URL, onError, } = {}) {
4
5
  const client = createClient({ url });
5
6
  // Always attach a listener even if
@@ -9,3 +10,25 @@ export function createRedis({ url = config.value.REDIS_URL, onError, } = {}) {
9
10
  });
10
11
  return client;
11
12
  }
13
+ export async function connectRedis(redis, shouldContinue = true) {
14
+ try {
15
+ await withTimeout(redis.connect(), 10000);
16
+ }
17
+ catch (error) {
18
+ if (shouldContinue) {
19
+ return;
20
+ }
21
+ throw new Error('Failed to connect to Redis', { cause: error });
22
+ }
23
+ }
24
+ export async function stopRedis(redis, timeout = 10000) {
25
+ try {
26
+ return await withTimeout(redis.close(), timeout);
27
+ }
28
+ catch {
29
+ return redis.destroy();
30
+ }
31
+ }
32
+ export function pingRedis(redis) {
33
+ return withTimeout(redis.ping(), 1000);
34
+ }
@@ -1,5 +1,6 @@
1
1
  import 'dotenv/config';
2
2
  import EventEmitter from 'node:events';
3
- import failOnConsole from 'vitest-fail-on-console';
3
+ import { extendMatchers, failOnConsole } from './vitest/index.js';
4
4
  EventEmitter.setMaxListeners(20);
5
5
  failOnConsole();
6
+ extendMatchers(expect);
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { type EventEmitter } from 'node:events';
2
+ import { withTimeout, TimeoutError, delay } from '@devmoods/fetch';
3
+ export { withTimeout, TimeoutError, delay };
2
4
  /**
3
5
  * Start a high resolution timer
4
6
  */
@@ -22,4 +24,3 @@ declare function readEnvNumber(key: string, defaultValue: number): number;
22
24
  declare function readEnvBoolean(key: string, defaultValue: boolean): boolean;
23
25
  export declare const envPort: (defaultPort?: number) => number;
24
26
  export declare function waitForEvent(emitter: EventEmitter, event: string): Promise<unknown>;
25
- export {};
package/dist/utils.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import {} from 'node:events';
2
+ import { withTimeout, TimeoutError, delay } from '@devmoods/fetch';
3
+ export { withTimeout, TimeoutError, delay };
2
4
  /**
3
5
  * Start a high resolution timer
4
6
  */
@@ -0,0 +1 @@
1
+ export * from './matchers.js';
@@ -0,0 +1 @@
1
+ export * from './matchers.js';
@@ -0,0 +1,11 @@
1
+ import { type ExpectStatic } from 'vitest';
2
+ import failOnConsole from 'vitest-fail-on-console';
3
+ interface CustomMatchers<R = unknown> {
4
+ toMatchResponse: (statusCode: number, body: unknown) => R;
5
+ }
6
+ declare module 'vitest' {
7
+ interface Matchers<T = any> extends CustomMatchers<T> {
8
+ }
9
+ }
10
+ export { failOnConsole };
11
+ export declare function extendMatchers(expect: ExpectStatic): void;
@@ -0,0 +1,61 @@
1
+ import {} from 'vitest';
2
+ import failOnConsole from 'vitest-fail-on-console';
3
+ export { failOnConsole };
4
+ export function extendMatchers(expect) {
5
+ expect.extend({
6
+ toMatchResponse(received, expectedStatusCode, expectedBody) {
7
+ const { isNot } = this;
8
+ // Check if received is a proper response object
9
+ if (!received ||
10
+ typeof received !== 'object' ||
11
+ !('status' in received)) {
12
+ return {
13
+ pass: false,
14
+ message: () => `Expected response object with status property, but received: ${this.utils.printReceived(received)}`,
15
+ };
16
+ }
17
+ const { status, body } = received;
18
+ // Check status code
19
+ const statusMatches = status === expectedStatusCode;
20
+ // Check body (if provided)
21
+ let bodyMatches = true;
22
+ let bodyMessage = '';
23
+ if (expectedBody !== undefined) {
24
+ if (typeof expectedBody === 'object' && expectedBody !== null) {
25
+ // For objects, do a deep equality check
26
+ bodyMatches = this.equals(body, expectedBody);
27
+ if (!bodyMatches) {
28
+ bodyMessage = `\n\nExpected body:\n${this.utils.printExpected(expectedBody)}\nReceived body:\n${this.utils.printReceived(body)}`;
29
+ }
30
+ }
31
+ else {
32
+ // For primitives, do a simple equality check
33
+ bodyMatches = body === expectedBody;
34
+ if (!bodyMatches) {
35
+ bodyMessage = `\n\nExpected body: ${this.utils.printExpected(expectedBody)}\nReceived body: ${this.utils.printReceived(body)}`;
36
+ }
37
+ }
38
+ }
39
+ const pass = statusMatches && bodyMatches;
40
+ let message = '';
41
+ if (!statusMatches) {
42
+ message += `Expected status: ${this.utils.printExpected(expectedStatusCode)}\nReceived status: ${this.utils.printReceived(status)}`;
43
+ }
44
+ if (!bodyMatches) {
45
+ message += bodyMessage;
46
+ }
47
+ return {
48
+ pass,
49
+ message: () => {
50
+ if (pass && isNot) {
51
+ return `Expected response not to match status ${expectedStatusCode}${expectedBody !== undefined ? ` with body ${this.utils.printExpected(expectedBody)}` : ''}`;
52
+ }
53
+ if (!pass && !isNot) {
54
+ return `Expected response to match status ${expectedStatusCode}${expectedBody !== undefined ? ` with body ${this.utils.printExpected(expectedBody)}` : ''}\n\n${message}`;
55
+ }
56
+ return message;
57
+ },
58
+ };
59
+ },
60
+ });
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devmoods/express-extras",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Kubernetes-ready utilities for rapid Node.js application development",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,6 +29,12 @@
29
29
  "types": "./dist/vite/types.d.ts",
30
30
  "default": "./dist/vite/types.js"
31
31
  }
32
+ },
33
+ "./vitest": {
34
+ "import": {
35
+ "types": "./dist/vitest/index.d.ts",
36
+ "default": "./dist/vitest/index.js"
37
+ }
32
38
  }
33
39
  },
34
40
  "author": {
@@ -65,9 +71,9 @@
65
71
  },
66
72
  "dependencies": {
67
73
  "@devmoods/fetch": "^4.2.0",
68
- "@hey-api/openapi-ts": "^0.82.5",
69
- "@sentry/node": "^10.10.0",
70
- "@sentry/profiling-node": "^10.10.0",
74
+ "@hey-api/openapi-ts": "^0.83.1",
75
+ "@sentry/node": "^10.11.0",
76
+ "@sentry/profiling-node": "^10.11.0",
71
77
  "@standard-schema/spec": "^1.0.0",
72
78
  "@types/bcrypt": "^6.0.0",
73
79
  "@types/cookie-parser": "^1.4.9",
@@ -82,7 +88,7 @@
82
88
  "argon2": "^0.44.0",
83
89
  "bcrypt": "^6.0.0",
84
90
  "chalk": "^5.6.2",
85
- "commander": "^14.0.0",
91
+ "commander": "^14.0.1",
86
92
  "compose-middleware": "^5.0.1",
87
93
  "cookie-parser": "^1.4.7",
88
94
  "dotenv": "^17.2.2",
@@ -95,8 +101,8 @@
95
101
  "pg-structure": "^7.15.3",
96
102
  "redis": "^5.8.2",
97
103
  "sonda": "^0.9.0",
98
- "sql-formatter": "^15.6.8",
99
- "uuid": "^12.0.0"
104
+ "sql-formatter": "^15.6.9",
105
+ "uuid": "^13.0.0"
100
106
  },
101
107
  "prettier": {
102
108
  "singleQuote": true
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};