@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.
@@ -0,0 +1,2 @@
1
+ import { type Request } from 'express';
2
+ export declare function prefersHtml(req: Request): boolean;
@@ -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
+ }
@@ -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.accepts('html')) {
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.accepts('html')) {
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, expiresIn: number): Promise<void>;
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, expiresIn: number): Promise<void>;
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, expiresIn) {
12
+ async set(token, userId, expiresInSeconds) {
13
13
  await this.redis.set(`${this.keyPrefix}${token}`, userId, {
14
- EX: expiresIn,
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, expiresIn) {
27
- this.#tokens.set(token, [userId, Date.now() + expiresIn]);
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);
@@ -6,7 +6,8 @@ export interface AnyUser {
6
6
  [key: string]: any;
7
7
  }
8
8
  export declare abstract class TokenStorage {
9
- abstract set(token: string, userId: string, expiresIn: number): Promise<void>;
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.accepts('html')) {
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.accepts('html')) {
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.accepts('html')) {
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: uid, r: randomBytes };
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, String(uid), config.AUTH_TEMP_TOKEN_EXPIRES);
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 baseKey = typeof options === 'object' && options != null
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
  };
@@ -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
- publish: (channel: string, message: string) => Promise<number>;
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<RedisClient>;
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, message);
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
- return subscriber;
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
- const subscriber = await backend.createSubscriber();
43
- const heartbeat = setInterval(() => {
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
- if (id) {
69
- writeChunk(res, `id: ${id}\n`, state);
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
- await subscriber.subscribe(_channels.map(withKeyPrefix), (message) => {
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
- next(error);
315
+ if (!disconnected) {
316
+ next(error);
317
+ }
143
318
  }
144
319
  };
145
320
  };
146
321
  const publish = (channel, message) => {
147
- return backend.publish(withKeyPrefix(channel), JSON.stringify(message));
322
+ return backend.publish(channel, JSON.stringify(message));
148
323
  };
149
324
  const close = async () => {
150
325
  for (const closeSubscriber of subscribers) {
package/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ export class CLI {
36
36
  }
37
37
  catch (error) {
38
38
  this.redLine(error.message);
39
+ process.exitCode = 1;
39
40
  /* v8 ignore next -- @preserve */
40
41
  if (this.options.printStackTraces) {
41
42
  this.writeLine('\nStack trace:\n', stderr);