@devmoods/express-extras 0.76.1 → 0.77.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/contentNegotiation.d.ts +2 -0
- package/dist/auth/contentNegotiation.js +9 -0
- package/dist/auth/google.js +2 -1
- package/dist/auth/passwordless.js +2 -1
- package/dist/auth/tokenStorage.d.ts +2 -2
- package/dist/auth/tokenStorage.js +4 -4
- package/dist/auth/types.d.ts +2 -1
- package/dist/auth.js +27 -20
- package/dist/cache.js +4 -4
- package/dist/channels.d.ts +26 -3
- package/dist/channels.js +231 -56
- package/dist/cli.js +1 -0
- package/dist/context.js +6 -3
- package/dist/email.d.ts +1 -1
- package/dist/faktory.d.ts +12 -25
- package/dist/faktory.js +39 -51
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +52 -0
- package/dist/jobs.js +58 -0
- package/dist/listen.js +37 -6
- package/dist/rate-limit.d.ts +11 -3
- package/dist/rate-limit.js +52 -22
- package/dist/redis-jobs.d.ts +23 -0
- package/dist/redis-jobs.js +185 -0
- package/dist/routes.js +95 -60
- package/dist/streams.js +1 -1
- package/dist/validate.js +6 -6
- package/dist/vite/defineViteConfig.d.ts +0 -1
- package/dist/vite/ssr.d.ts +17 -1
- package/dist/vite/ssr.js +21 -2
- package/dist/vitest/matchers.d.ts +16 -5
- package/dist/vitest/matchers.js +66 -3
- package/dist/vitest/testClient.js +15 -3
- package/package.json +23 -23
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {} from 'express';
|
|
2
|
+
const explicitHtmlPattern = /(?:^|,)\s*text\/html\s*(?:;|,|$)/i;
|
|
3
|
+
export function prefersHtml(req) {
|
|
4
|
+
const accept = req.get('accept');
|
|
5
|
+
if (!accept || !explicitHtmlPattern.test(accept)) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
return req.accepts(['html', 'json']) === 'html';
|
|
9
|
+
}
|
package/dist/auth/google.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { config } from '@devmoods/config';
|
|
2
2
|
import { type } from 'arktype';
|
|
3
|
+
import { prefersHtml } from './contentNegotiation.js';
|
|
3
4
|
import { exchangeGoogleAuthorizationCode, verifyGoogleIdToken, } from './google-client.js';
|
|
4
5
|
import { createTokenResponse } from './token.js';
|
|
5
6
|
import {} from './types.js';
|
|
@@ -99,7 +100,7 @@ export function GoogleAuthProvider({ clientId = config.GOOGLE_AUTH_CLIENT_ID, cl
|
|
|
99
100
|
});
|
|
100
101
|
const accessToken = await context.login(req, res, user);
|
|
101
102
|
logger.info('google-login', { email: user.email });
|
|
102
|
-
if (req
|
|
103
|
+
if (prefersHtml(req)) {
|
|
103
104
|
return res.redirect('/');
|
|
104
105
|
}
|
|
105
106
|
return createTokenResponse(accessToken);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import { DoesNotExistError } from '@devmoods/postgres';
|
|
3
|
+
import { prefersHtml } from './contentNegotiation.js';
|
|
3
4
|
import { createTokenResponse } from './token.js';
|
|
4
5
|
import {} from './types.js';
|
|
5
6
|
import { BadRequest } from '../errors.js';
|
|
@@ -53,7 +54,7 @@ export function PasswordlessAuthProvider(options) {
|
|
|
53
54
|
}
|
|
54
55
|
const accessToken = await context.login(req, res, user);
|
|
55
56
|
logger.info('passwordless login', { tokenType, uid: user.id });
|
|
56
|
-
if (req
|
|
57
|
+
if (prefersHtml(req)) {
|
|
57
58
|
return res.redirect('/');
|
|
58
59
|
}
|
|
59
60
|
return createTokenResponse(accessToken);
|
|
@@ -4,13 +4,13 @@ export declare class RedisTokenStorage extends TokenStorage {
|
|
|
4
4
|
redis: RedisClient;
|
|
5
5
|
keyPrefix: string;
|
|
6
6
|
constructor(redis: RedisClient, keyPrefix?: string);
|
|
7
|
-
set(token: string, userId: string,
|
|
7
|
+
set(token: string, userId: string, expiresInSeconds: number): Promise<void>;
|
|
8
8
|
get(token: string): Promise<string | null>;
|
|
9
9
|
invalidate(token: string): Promise<void>;
|
|
10
10
|
}
|
|
11
11
|
export declare class MemoryTokenStorage extends TokenStorage {
|
|
12
12
|
#private;
|
|
13
|
-
set(token: string, userId: string,
|
|
13
|
+
set(token: string, userId: string, expiresInSeconds: number): Promise<void>;
|
|
14
14
|
get(token: string): Promise<string | null>;
|
|
15
15
|
invalidate(token: string): Promise<void>;
|
|
16
16
|
}
|
|
@@ -9,9 +9,9 @@ export class RedisTokenStorage extends TokenStorage {
|
|
|
9
9
|
this.redis = redis;
|
|
10
10
|
this.keyPrefix = keyPrefix;
|
|
11
11
|
}
|
|
12
|
-
async set(token, userId,
|
|
12
|
+
async set(token, userId, expiresInSeconds) {
|
|
13
13
|
await this.redis.set(`${this.keyPrefix}${token}`, userId, {
|
|
14
|
-
EX:
|
|
14
|
+
EX: expiresInSeconds,
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
async get(token) {
|
|
@@ -23,8 +23,8 @@ export class RedisTokenStorage extends TokenStorage {
|
|
|
23
23
|
}
|
|
24
24
|
export class MemoryTokenStorage extends TokenStorage {
|
|
25
25
|
#tokens = new Map();
|
|
26
|
-
async set(token, userId,
|
|
27
|
-
this.#tokens.set(token, [userId, Date.now() +
|
|
26
|
+
async set(token, userId, expiresInSeconds) {
|
|
27
|
+
this.#tokens.set(token, [userId, Date.now() + expiresInSeconds * 1000]);
|
|
28
28
|
}
|
|
29
29
|
async get(token) {
|
|
30
30
|
const entry = this.#tokens.get(token);
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export interface AnyUser {
|
|
|
6
6
|
[key: string]: any;
|
|
7
7
|
}
|
|
8
8
|
export declare abstract class TokenStorage {
|
|
9
|
-
|
|
9
|
+
/** Store a token for the given number of seconds. */
|
|
10
|
+
abstract set(token: string, userId: string, expiresInSeconds: number): Promise<void>;
|
|
10
11
|
abstract get(token: string): Promise<string | null>;
|
|
11
12
|
abstract invalidate(token: string): Promise<void>;
|
|
12
13
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import { config } from '@devmoods/config';
|
|
3
3
|
import {} from 'express';
|
|
4
|
+
import { prefersHtml } from './auth/contentNegotiation.js';
|
|
4
5
|
import { InvalidToken, PermissionDenied } from './auth/errors.js';
|
|
5
6
|
import { createAccessToken, decodeAccessToken, extractAccessTokenFromRequest, setAccessTokenCookie, tokenSchema, } from './auth/token.js';
|
|
6
7
|
import {} from './auth/types.js';
|
|
@@ -35,20 +36,26 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
35
36
|
const accessToken = await createAccessToken({ uid: user.id }, sessionTokenVersion);
|
|
36
37
|
const sessionId = sha256(accessToken.token);
|
|
37
38
|
await tokenStorage.set(sessionId, String(user.id), accessToken.expiresIn);
|
|
39
|
+
try {
|
|
40
|
+
if (!isRefresh) {
|
|
41
|
+
await onUserLoggedIn?.(user, {
|
|
42
|
+
/* v8 ignore start -- @preserve */
|
|
43
|
+
ip: req.ip ?? '',
|
|
44
|
+
userAgent: req.headers['user-agent'] ?? '',
|
|
45
|
+
/* v8 ignore end -- @preserve */
|
|
46
|
+
sessionId,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await tokenStorage.invalidate(sessionId);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
38
54
|
if (previousSessionId && previousSessionId !== sessionId) {
|
|
39
55
|
await tokenStorage.invalidate(previousSessionId);
|
|
40
56
|
}
|
|
41
57
|
setAccessTokenCookie(res, accessToken.token);
|
|
42
58
|
setCurrentUser(user);
|
|
43
|
-
if (!isRefresh) {
|
|
44
|
-
await onUserLoggedIn?.(user, {
|
|
45
|
-
/* v8 ignore start -- @preserve */
|
|
46
|
-
ip: req.ip ?? '',
|
|
47
|
-
userAgent: req.headers['user-agent'] ?? '',
|
|
48
|
-
/* v8 ignore end -- @preserve */
|
|
49
|
-
sessionId,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
59
|
return accessToken;
|
|
53
60
|
};
|
|
54
61
|
const logout = async (req, res) => {
|
|
@@ -171,7 +178,7 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
171
178
|
logger.error(error);
|
|
172
179
|
/* v8 ignore next -- @preserve */
|
|
173
180
|
if (error instanceof InvalidToken) {
|
|
174
|
-
if (req
|
|
181
|
+
if (prefersHtml(req)) {
|
|
175
182
|
return res.redirect(loginUrl);
|
|
176
183
|
}
|
|
177
184
|
return next(Unauthorized('Invalid token', { cause: error }));
|
|
@@ -197,14 +204,14 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
197
204
|
catch (error) {
|
|
198
205
|
logger.error(error);
|
|
199
206
|
if (error instanceof PermissionDenied) {
|
|
200
|
-
if (req
|
|
207
|
+
if (prefersHtml(req)) {
|
|
201
208
|
return res.redirect(loginUrl);
|
|
202
209
|
}
|
|
203
210
|
return next(Forbidden('Permission denied', { cause: error }));
|
|
204
211
|
}
|
|
205
212
|
/* v8 ignore next -- @preserve */
|
|
206
213
|
if (error instanceof InvalidToken) {
|
|
207
|
-
if (req
|
|
214
|
+
if (prefersHtml(req)) {
|
|
208
215
|
return res.redirect(loginUrl);
|
|
209
216
|
}
|
|
210
217
|
return next(Unauthorized(`Invalid token: ${error.message}`, { cause: error }));
|
|
@@ -265,19 +272,19 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
265
272
|
*/
|
|
266
273
|
async function createTemporaryToken(user) {
|
|
267
274
|
try {
|
|
268
|
-
const uid = user === null ? 'anonymous' : user.id;
|
|
275
|
+
const uid = user === null ? 'anonymous' : String(user.id ?? '');
|
|
276
|
+
if (!uid) {
|
|
277
|
+
throw new Error('User ID is required to create a token');
|
|
278
|
+
}
|
|
269
279
|
const randomBytes = crypto.randomBytes(32).toString('hex');
|
|
270
|
-
const payload = { v: '1', uid
|
|
280
|
+
const payload = { v: '1', uid, r: randomBytes };
|
|
271
281
|
const signature = crypto
|
|
272
282
|
.createHmac('sha256', config.AUTH_JWT_SECRET)
|
|
273
283
|
.update(JSON.stringify(payload))
|
|
274
284
|
.digest('base64')
|
|
275
285
|
.slice(0, 12);
|
|
276
|
-
if (!uid) {
|
|
277
|
-
throw new Error('User ID is required to create a token');
|
|
278
|
-
}
|
|
279
286
|
const temporaryToken = Buffer.from(JSON.stringify({ payload, signature })).toString('base64url');
|
|
280
|
-
await tokenStorage.set(temporaryToken,
|
|
287
|
+
await tokenStorage.set(temporaryToken, uid, config.AUTH_TEMP_TOKEN_EXPIRES);
|
|
281
288
|
return temporaryToken;
|
|
282
289
|
}
|
|
283
290
|
catch (error) {
|
|
@@ -304,14 +311,14 @@ export function createAuth({ getUserById, filterUser, tokenStorage, providers, l
|
|
|
304
311
|
if (expectedSignature !== decoded.signature) {
|
|
305
312
|
throw new InvalidToken('Invalid signature');
|
|
306
313
|
}
|
|
307
|
-
if (user !== null && decoded.payload.uid !== user.id) {
|
|
314
|
+
if (user !== null && decoded.payload.uid !== String(user.id)) {
|
|
308
315
|
throw new InvalidToken(`Invalid user ${user.id}`);
|
|
309
316
|
}
|
|
310
317
|
const storedUid = await tokenStorage.get(token);
|
|
311
318
|
if (!storedUid) {
|
|
312
319
|
throw new InvalidToken('Invalid token not found');
|
|
313
320
|
}
|
|
314
|
-
if (user !== null && storedUid !== user.id) {
|
|
321
|
+
if (user !== null && storedUid !== String(user.id)) {
|
|
315
322
|
throw new InvalidToken(`Invalid user ${user.id}`);
|
|
316
323
|
}
|
|
317
324
|
if (consume) {
|
package/dist/cache.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { config } from '@devmoods/config';
|
|
2
3
|
import {} from './redis.js';
|
|
3
4
|
const MAX_CACHE_KEY_SIZE_BYTES = 256;
|
|
@@ -123,11 +124,10 @@ export function createCache(adapter) {
|
|
|
123
124
|
return result;
|
|
124
125
|
}
|
|
125
126
|
function cachedFn(options, fn) {
|
|
127
|
+
const explicitKey = typeof options === 'object' && options != null ? options.key : undefined;
|
|
128
|
+
const baseKey = explicitKey ?? randomUUID();
|
|
126
129
|
const getCacheKey = (...args) => {
|
|
127
|
-
const
|
|
128
|
-
? options.key
|
|
129
|
-
: undefined;
|
|
130
|
-
const cacheKey = `${baseKey ?? Buffer.from(fn.toString()).toString('hex')}-${hashKey(args)}`;
|
|
130
|
+
const cacheKey = `${baseKey}-${hashKey(args)}`;
|
|
131
131
|
assertCacheKeySize(cacheKey);
|
|
132
132
|
return cacheKey;
|
|
133
133
|
};
|
package/dist/channels.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Postgres } from '@devmoods/postgres';
|
|
1
2
|
import { type StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
3
|
import { type Request } from 'express';
|
|
3
4
|
import { type RedisClient } from './redis.js';
|
|
@@ -7,18 +8,40 @@ export interface EventStreamSseOptions<Schema extends StandardSchemaV1> {
|
|
|
7
8
|
getIdFromMessage?: (message: StandardSchemaV1.InferOutput<Schema>) => string | undefined;
|
|
8
9
|
}
|
|
9
10
|
export interface EventStreamSubscriber {
|
|
11
|
+
/** Subscribe to logical channel names; any transport-specific prefixes belong to the backend. */
|
|
10
12
|
subscribe: (channels: string[], listener: (message: string) => void) => Promise<void>;
|
|
11
13
|
close: () => void;
|
|
12
14
|
}
|
|
13
15
|
export interface EventStreamBackend {
|
|
14
|
-
|
|
16
|
+
/** Publish to a logical channel, returning a subscriber count when the backend provides one. */
|
|
17
|
+
publish: (channel: string, message: string) => Promise<number | void>;
|
|
15
18
|
createSubscriber: () => Promise<EventStreamSubscriber>;
|
|
16
19
|
}
|
|
20
|
+
/** In-process Pub/Sub. Share an instance to share channels; messages are not retained. */
|
|
21
|
+
export declare class MemoryEventStreamBackend implements EventStreamBackend {
|
|
22
|
+
private subscribers;
|
|
23
|
+
publish(channel: string, message: string): Promise<number>;
|
|
24
|
+
createSubscriber(): Promise<EventStreamSubscriber>;
|
|
25
|
+
}
|
|
17
26
|
export declare class RedisEventStreamBackend implements EventStreamBackend {
|
|
18
27
|
redis: RedisClient;
|
|
28
|
+
private keyPrefix;
|
|
19
29
|
constructor(redis: RedisClient);
|
|
20
30
|
publish(channel: string, message: string): Promise<number>;
|
|
21
|
-
createSubscriber(): Promise<
|
|
31
|
+
createSubscriber(): Promise<EventStreamSubscriber>;
|
|
32
|
+
}
|
|
33
|
+
export interface PostgresEventStreamBackendOptions {
|
|
34
|
+
/** Isolate streams sharing a database. Defaults to "events". */
|
|
35
|
+
namespace?: string;
|
|
36
|
+
}
|
|
37
|
+
/** LISTEN/NOTIFY Pub/Sub. Each subscriber holds a pool connection until closed. */
|
|
38
|
+
export declare class PostgresEventStreamBackend implements EventStreamBackend {
|
|
39
|
+
private postgres;
|
|
40
|
+
private namespace;
|
|
41
|
+
constructor(postgres: Postgres, { namespace }?: PostgresEventStreamBackendOptions);
|
|
42
|
+
private channelName;
|
|
43
|
+
publish(channel: string, message: string): Promise<void>;
|
|
44
|
+
createSubscriber(): Promise<EventStreamSubscriber>;
|
|
22
45
|
}
|
|
23
46
|
export interface EventStreamOptions<Schema extends StandardSchemaV1> {
|
|
24
47
|
schema: Schema;
|
|
@@ -26,6 +49,6 @@ export interface EventStreamOptions<Schema extends StandardSchemaV1> {
|
|
|
26
49
|
}
|
|
27
50
|
export declare function createEventStream<Channels extends string, Schema extends StandardSchemaV1>(backend: EventStreamBackend, options: EventStreamOptions<Schema>): {
|
|
28
51
|
sse: <const Path extends string>({ channels, getIdFromMessage, }: EventStreamSseOptions<Schema>) => TypedRequestHandler<ExtractParams<Path>, any, any, any>;
|
|
29
|
-
publish: (channel: Channels, message: StandardSchemaV1.InferOutput<Schema>) => Promise<number>;
|
|
52
|
+
publish: (channel: Channels, message: StandardSchemaV1.InferOutput<Schema>) => Promise<number | void>;
|
|
30
53
|
close: () => Promise<void>;
|
|
31
54
|
};
|
package/dist/channels.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { config } from '@devmoods/config';
|
|
3
|
+
import { escapePostgresIdentifier } from '@devmoods/postgres';
|
|
2
4
|
import {} from '@standard-schema/spec';
|
|
3
5
|
import {} from 'express';
|
|
4
6
|
import { requestContext } from './context.js';
|
|
@@ -14,13 +16,59 @@ const defaultGetIdFromMessage = (message) => {
|
|
|
14
16
|
}
|
|
15
17
|
return undefined;
|
|
16
18
|
};
|
|
19
|
+
/** In-process Pub/Sub. Share an instance to share channels; messages are not retained. */
|
|
20
|
+
export class MemoryEventStreamBackend {
|
|
21
|
+
subscribers = new Set();
|
|
22
|
+
async publish(channel, message) {
|
|
23
|
+
const deliveries = [...this.subscribers].map((subscriptions) => [
|
|
24
|
+
...(subscriptions.get(channel) ?? []),
|
|
25
|
+
]);
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const listeners of deliveries) {
|
|
28
|
+
if (listeners.length > 0) {
|
|
29
|
+
count++;
|
|
30
|
+
for (const listener of listeners) {
|
|
31
|
+
listener(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return count;
|
|
36
|
+
}
|
|
37
|
+
async createSubscriber() {
|
|
38
|
+
const subscriptions = new Map();
|
|
39
|
+
let closed = false;
|
|
40
|
+
return {
|
|
41
|
+
subscribe: async (channels, listener) => {
|
|
42
|
+
if (closed) {
|
|
43
|
+
throw new Error('Subscriber is closed');
|
|
44
|
+
}
|
|
45
|
+
for (const channel of channels) {
|
|
46
|
+
let listeners = subscriptions.get(channel);
|
|
47
|
+
if (!listeners) {
|
|
48
|
+
listeners = new Set();
|
|
49
|
+
subscriptions.set(channel, listeners);
|
|
50
|
+
}
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
}
|
|
53
|
+
this.subscribers.add(subscriptions);
|
|
54
|
+
},
|
|
55
|
+
close: () => {
|
|
56
|
+
closed = true;
|
|
57
|
+
this.subscribers.delete(subscriptions);
|
|
58
|
+
subscriptions.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
17
63
|
export class RedisEventStreamBackend {
|
|
18
64
|
redis;
|
|
65
|
+
keyPrefix;
|
|
19
66
|
constructor(redis) {
|
|
20
67
|
this.redis = redis;
|
|
68
|
+
this.keyPrefix = `${config.REDIS_KEY_PREFIX}events:`;
|
|
21
69
|
}
|
|
22
70
|
publish(channel, message) {
|
|
23
|
-
return this.redis.publish(channel
|
|
71
|
+
return this.redis.publish(`${this.keyPrefix}${channel}`, message);
|
|
24
72
|
}
|
|
25
73
|
async createSubscriber() {
|
|
26
74
|
const subscriber = this.redis.duplicate();
|
|
@@ -29,23 +77,181 @@ export class RedisEventStreamBackend {
|
|
|
29
77
|
logger.error(error);
|
|
30
78
|
});
|
|
31
79
|
await subscriber.connect();
|
|
32
|
-
|
|
80
|
+
let closed = false;
|
|
81
|
+
return {
|
|
82
|
+
subscribe: async (channels, listener) => {
|
|
83
|
+
if (closed) {
|
|
84
|
+
throw new Error('Subscriber is closed');
|
|
85
|
+
}
|
|
86
|
+
return subscriber.subscribe(channels.map((channel) => `${this.keyPrefix}${channel}`), listener);
|
|
87
|
+
},
|
|
88
|
+
close: () => {
|
|
89
|
+
if (closed) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
closed = true;
|
|
93
|
+
subscriber.close();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** LISTEN/NOTIFY Pub/Sub. Each subscriber holds a pool connection until closed. */
|
|
99
|
+
export class PostgresEventStreamBackend {
|
|
100
|
+
postgres;
|
|
101
|
+
namespace;
|
|
102
|
+
constructor(postgres, { namespace = 'events' } = {}) {
|
|
103
|
+
this.postgres = postgres;
|
|
104
|
+
this.namespace = namespace;
|
|
105
|
+
}
|
|
106
|
+
channelName(channel) {
|
|
107
|
+
// Keep arbitrary logical channels within PostgreSQL's 63-byte identifier limit.
|
|
108
|
+
const hash = createHash('sha256')
|
|
109
|
+
.update(JSON.stringify([this.namespace, channel]))
|
|
110
|
+
.digest('base64url');
|
|
111
|
+
return `events_${hash}`;
|
|
112
|
+
}
|
|
113
|
+
async publish(channel, message) {
|
|
114
|
+
if (Buffer.byteLength(message, 'utf8') >= 8000) {
|
|
115
|
+
throw new RangeError('Postgres event messages must be smaller than 8000 bytes');
|
|
116
|
+
}
|
|
117
|
+
// Use the wrapper so notifications participate in the current transaction.
|
|
118
|
+
await this.postgres.query('SELECT pg_notify($1, $2)', [
|
|
119
|
+
this.channelName(channel),
|
|
120
|
+
message,
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
async createSubscriber() {
|
|
124
|
+
// LISTEN needs its own session, independent of request transactions.
|
|
125
|
+
const client = await this.postgres.getCurrentPool().connect();
|
|
126
|
+
const subscriptions = new Map();
|
|
127
|
+
let closed = false;
|
|
128
|
+
const onNotification = (notification) => {
|
|
129
|
+
for (const listener of [
|
|
130
|
+
...(subscriptions.get(notification.channel) ?? []),
|
|
131
|
+
]) {
|
|
132
|
+
listener(notification.payload ?? '');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const close = () => {
|
|
136
|
+
if (closed) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
closed = true;
|
|
140
|
+
subscriptions.clear();
|
|
141
|
+
client.off('notification', onNotification);
|
|
142
|
+
client.off('error', onError);
|
|
143
|
+
client.off('end', close);
|
|
144
|
+
// Destroy the session so LISTEN state can never leak to another pool user.
|
|
145
|
+
client.release(true);
|
|
146
|
+
};
|
|
147
|
+
const onError = (error) => {
|
|
148
|
+
logger.error(error);
|
|
149
|
+
close();
|
|
150
|
+
};
|
|
151
|
+
client.on('notification', onNotification);
|
|
152
|
+
client.on('error', onError);
|
|
153
|
+
client.on('end', close);
|
|
154
|
+
return {
|
|
155
|
+
subscribe: async (channels, listener) => {
|
|
156
|
+
if (closed) {
|
|
157
|
+
throw new Error('Subscriber is closed');
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
for (const channel of new Set(channels.map((value) => this.channelName(value)))) {
|
|
161
|
+
let listeners = subscriptions.get(channel);
|
|
162
|
+
if (!listeners) {
|
|
163
|
+
listeners = new Set();
|
|
164
|
+
subscriptions.set(channel, listeners);
|
|
165
|
+
}
|
|
166
|
+
listeners.add(listener);
|
|
167
|
+
// Repeating LISTEN is harmless and also awaits concurrent subscriptions.
|
|
168
|
+
await client.query(`LISTEN ${escapePostgresIdentifier(channel)}`);
|
|
169
|
+
if (closed) {
|
|
170
|
+
throw new Error('Subscriber is closed');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
close();
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
close,
|
|
180
|
+
};
|
|
33
181
|
}
|
|
34
182
|
}
|
|
35
183
|
export function createEventStream(backend, options) {
|
|
36
|
-
const keyPrefix = `${config.REDIS_KEY_PREFIX}events:`;
|
|
37
|
-
const withKeyPrefix = (key) => `${keyPrefix}${key}`;
|
|
38
184
|
const subscribers = new Set();
|
|
39
185
|
const sse = ({ channels, getIdFromMessage = defaultGetIdFromMessage, }) => {
|
|
40
186
|
return async (req, res, next) => {
|
|
187
|
+
let subscriber;
|
|
188
|
+
let subscriberClosed = false;
|
|
189
|
+
let heartbeat;
|
|
190
|
+
let closed = false;
|
|
191
|
+
const state = {
|
|
192
|
+
draining: false,
|
|
193
|
+
queue: [],
|
|
194
|
+
maxQueue: 1000,
|
|
195
|
+
};
|
|
196
|
+
const store = requestContext.getStore();
|
|
197
|
+
const closeSubscriber = () => {
|
|
198
|
+
if (!subscriber || subscriberClosed) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
subscriberClosed = true;
|
|
202
|
+
subscriber.close();
|
|
203
|
+
};
|
|
204
|
+
const close = () => {
|
|
205
|
+
if (closed) {
|
|
206
|
+
closeSubscriber();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
closed = true;
|
|
210
|
+
requestContext.run(store, () => {
|
|
211
|
+
logger.info('event stream close');
|
|
212
|
+
if (heartbeat) {
|
|
213
|
+
clearInterval(heartbeat);
|
|
214
|
+
}
|
|
215
|
+
closeSubscriber();
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const gracefulClose = () => {
|
|
219
|
+
/* v8 ignore next -- @preserve */
|
|
220
|
+
if (closed) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
/* v8 ignore next -- @preserve */
|
|
224
|
+
if (!res.writableEnded) {
|
|
225
|
+
writeChunk(res, 'event: close\ndata: shutdown\n\n', state);
|
|
226
|
+
res.end();
|
|
227
|
+
}
|
|
228
|
+
close();
|
|
229
|
+
};
|
|
230
|
+
const cleanup = () => {
|
|
231
|
+
res.off('close', cleanup);
|
|
232
|
+
res.off('finish', cleanup);
|
|
233
|
+
req.off('aborted', cleanup);
|
|
234
|
+
req.socket.off('error', cleanup);
|
|
235
|
+
subscribers.delete(gracefulClose);
|
|
236
|
+
close();
|
|
237
|
+
};
|
|
238
|
+
const isDisconnected = () => closed || req.aborted || res.destroyed || res.writableEnded;
|
|
239
|
+
subscribers.add(gracefulClose);
|
|
240
|
+
res.on('close', cleanup);
|
|
241
|
+
res.on('finish', cleanup);
|
|
242
|
+
req.on('aborted', cleanup);
|
|
243
|
+
req.socket.on('error', cleanup);
|
|
41
244
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
245
|
+
subscriber = await backend.createSubscriber();
|
|
246
|
+
if (isDisconnected()) {
|
|
247
|
+
cleanup();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
heartbeat = setInterval(() => {
|
|
44
251
|
try {
|
|
45
252
|
/* v8 ignore next -- @preserve */
|
|
46
253
|
if (!res.writableEnded) {
|
|
47
|
-
writeChunk(res, 'event: heartbeat\n', state);
|
|
48
|
-
writeChunk(res, 'data: ping\n\n', state);
|
|
254
|
+
writeChunk(res, 'event: heartbeat\ndata: ping\n\n', state);
|
|
49
255
|
}
|
|
50
256
|
}
|
|
51
257
|
catch (error) {
|
|
@@ -53,23 +259,14 @@ export function createEventStream(backend, options) {
|
|
|
53
259
|
logger.error(error);
|
|
54
260
|
}
|
|
55
261
|
}, options.heartbeatInterval ?? 15000);
|
|
56
|
-
const state = {
|
|
57
|
-
draining: false,
|
|
58
|
-
queue: [],
|
|
59
|
-
maxQueue: 1000,
|
|
60
|
-
};
|
|
61
|
-
const store = requestContext.getStore();
|
|
62
262
|
const listener = (value) => {
|
|
63
263
|
try {
|
|
64
264
|
requestContext.run(store, () => {
|
|
65
265
|
/* v8 ignore next -- @preserve */
|
|
66
266
|
if (!res.writableEnded) {
|
|
67
267
|
const id = getIdFromMessage?.(value);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
writeChunk(res, 'event: data\n', state);
|
|
72
|
-
writeChunk(res, `data: ${JSON.stringify(value)}\n\n`, state);
|
|
268
|
+
const event = `${id ? `id: ${id}\n` : ''}event: data\ndata: ${JSON.stringify(value)}\n\n`;
|
|
269
|
+
writeChunk(res, event, state);
|
|
73
270
|
}
|
|
74
271
|
});
|
|
75
272
|
}
|
|
@@ -79,7 +276,11 @@ export function createEventStream(backend, options) {
|
|
|
79
276
|
}
|
|
80
277
|
};
|
|
81
278
|
const _channels = await channels(req);
|
|
82
|
-
|
|
279
|
+
if (isDisconnected()) {
|
|
280
|
+
cleanup();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
await subscriber.subscribe(_channels, (message) => {
|
|
83
284
|
let parsedMessage;
|
|
84
285
|
try {
|
|
85
286
|
parsedMessage = JSON.parse(message);
|
|
@@ -93,6 +294,10 @@ export function createEventStream(backend, options) {
|
|
|
93
294
|
listener(parsedMessage);
|
|
94
295
|
}
|
|
95
296
|
});
|
|
297
|
+
if (isDisconnected()) {
|
|
298
|
+
cleanup();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
96
301
|
logger.info('event stream subscribe', { channels: _channels });
|
|
97
302
|
const headers = {
|
|
98
303
|
'Content-Type': 'text/event-stream',
|
|
@@ -102,49 +307,19 @@ export function createEventStream(backend, options) {
|
|
|
102
307
|
};
|
|
103
308
|
res.writeHead(200, headers);
|
|
104
309
|
writeChunk(res, 'retry: 10000\n', state);
|
|
105
|
-
let closed = false;
|
|
106
|
-
const close = () => {
|
|
107
|
-
if (closed) {
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
closed = true;
|
|
111
|
-
requestContext.run(store, () => {
|
|
112
|
-
logger.info('event stream close');
|
|
113
|
-
clearInterval(heartbeat);
|
|
114
|
-
subscriber.close();
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
const gracefulClose = () => {
|
|
118
|
-
/* v8 ignore next -- @preserve */
|
|
119
|
-
if (closed) {
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
/* v8 ignore next -- @preserve */
|
|
123
|
-
if (!res.writableEnded) {
|
|
124
|
-
writeChunk(res, 'event: close\n', state);
|
|
125
|
-
writeChunk(res, 'data: shutdown\n\n', state);
|
|
126
|
-
res.end();
|
|
127
|
-
}
|
|
128
|
-
close();
|
|
129
|
-
};
|
|
130
|
-
subscribers.add(gracefulClose);
|
|
131
|
-
const cleanup = () => {
|
|
132
|
-
subscribers.delete(gracefulClose);
|
|
133
|
-
close();
|
|
134
|
-
};
|
|
135
|
-
res.on('close', cleanup);
|
|
136
|
-
res.on('finish', cleanup);
|
|
137
|
-
req.on('aborted', cleanup);
|
|
138
|
-
req.socket.on('error', cleanup);
|
|
139
310
|
}
|
|
140
311
|
catch (error) {
|
|
312
|
+
const disconnected = isDisconnected();
|
|
313
|
+
cleanup();
|
|
141
314
|
/* v8 ignore next -- @preserve */
|
|
142
|
-
|
|
315
|
+
if (!disconnected) {
|
|
316
|
+
next(error);
|
|
317
|
+
}
|
|
143
318
|
}
|
|
144
319
|
};
|
|
145
320
|
};
|
|
146
321
|
const publish = (channel, message) => {
|
|
147
|
-
return backend.publish(
|
|
322
|
+
return backend.publish(channel, JSON.stringify(message));
|
|
148
323
|
};
|
|
149
324
|
const close = async () => {
|
|
150
325
|
for (const closeSubscriber of subscribers) {
|