@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 +1 -1
- package/dist/channels.d.ts +6 -3
- package/dist/channels.js +107 -20
- package/dist/listen.d.ts +1 -0
- package/dist/listen.js +6 -2
- package/dist/redis.d.ts +3 -0
- package/dist/redis.js +23 -0
- package/dist/setupTests.js +2 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +2 -0
- package/dist/vitest/index.d.ts +1 -0
- package/dist/vitest/index.js +1 -0
- package/dist/vitest/matchers.d.ts +11 -0
- package/dist/vitest/matchers.js +61 -0
- package/package.json +13 -7
- package/dist/vite/vitest.d.ts +0 -1
- package/dist/vite/vitest.js +0 -1
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
|
|
200
|
+
throw new InvalidToken('User not logged in');
|
|
201
201
|
}
|
|
202
202
|
if (isAuthorized && !(await isAuthorized(user))) {
|
|
203
203
|
throw new PermissionDenied('Permission denied');
|
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
|
45
|
+
const subscribers = new Set();
|
|
46
|
+
const sse = ({ channels, getIdFromMessage = defaultGetIdFromMessage, }) => {
|
|
12
47
|
return async (req, res, next) => {
|
|
13
48
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
25
|
-
res
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
90
|
+
await options.beforeShutdown?.();
|
|
91
|
+
const gracefully = await server.stop();
|
|
91
92
|
await options.onShutdown?.();
|
|
92
|
-
logger.info('app shut down', {
|
|
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
|
+
}
|
package/dist/setupTests.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
2
|
import EventEmitter from 'node:events';
|
|
3
|
-
import failOnConsole from 'vitest
|
|
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
|
@@ -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.
|
|
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.
|
|
69
|
-
"@sentry/node": "^10.
|
|
70
|
-
"@sentry/profiling-node": "^10.
|
|
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.
|
|
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.
|
|
99
|
-
"uuid": "^
|
|
104
|
+
"sql-formatter": "^15.6.9",
|
|
105
|
+
"uuid": "^13.0.0"
|
|
100
106
|
},
|
|
101
107
|
"prettier": {
|
|
102
108
|
"singleQuote": true
|
package/dist/vite/vitest.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/vite/vitest.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|