@tstdl/base 0.93.171 → 0.93.172

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.
@@ -202,7 +202,7 @@ let ApiGateway = ApiGateway_1 = class ApiGateway {
202
202
  abortSignal: context.abortSignal,
203
203
  serverSentEvents: {
204
204
  get lastEventId() {
205
- return context.request.headers.tryGetSingle('Last-Event-ID');
205
+ return (context.request.headers.tryGetSingle('Last-Event-ID') ?? context.request.query.tryGetSingle('lastEventId'));
206
206
  },
207
207
  },
208
208
  tryGetToken: async () => {
@@ -1,4 +1,6 @@
1
+ import { DataStream, ServerSentEvents } from '../../../sse/index.js';
1
2
  import { toArray } from '../../../utils/array/array.js';
3
+ import { isDefined } from '../../../utils/type-guards.js';
2
4
  /**
3
5
  * Middleware that adds required CORS headers for SSE and cache busting.
4
6
  * @param context Gateway context.
@@ -6,13 +8,24 @@ import { toArray } from '../../../utils/array/array.js';
6
8
  */
7
9
  export async function serverSentEventsMiddleware(context, next) {
8
10
  await next();
9
- if ((context.response.bodyType != 'events') || (context.request.method != 'OPTIONS')) {
10
- return;
11
+ const isEvents = (context.response.bodyType == 'events');
12
+ const isOptions = (context.request.method == 'OPTIONS');
13
+ if (isEvents) {
14
+ context.response.headers.setIfMissing('Cache-Control', 'no-cache');
15
+ context.response.headers.setIfMissing('Connection', 'keep-alive');
16
+ context.response.headers.setIfMissing('X-Accel-Buffering', 'no');
17
+ }
18
+ if (isOptions) {
19
+ const requestMethod = context.request.headers.tryGetSingle('Access-Control-Request-Method') ?? context.request.method;
20
+ const endpointDefinition = context.api.endpoints.get(requestMethod)?.definition;
21
+ const isSse = isDefined(endpointDefinition) && ((endpointDefinition.result == DataStream) || (endpointDefinition.result == ServerSentEvents));
22
+ if (isSse || (endpointDefinition?.bustCache == true)) {
23
+ const existing = context.response.headers.tryGet('Access-Control-Allow-Headers');
24
+ const items = toArray(existing).flatMap((header) => header?.split(',') ?? []).map((header) => header.trim()).filter((header) => header.length > 0);
25
+ const headers = new Set(items);
26
+ headers.add('Cache-Control');
27
+ headers.add('Last-Event-ID');
28
+ context.response.headers.set('Access-Control-Allow-Headers', [...headers].join(', '));
29
+ }
11
30
  }
12
- const existing = context.response.headers.tryGet('Access-Control-Allow-Headers');
13
- const items = toArray(existing).flatMap((header) => header?.split(',') ?? []).map((header) => header.trim()).filter((header) => header.length > 0);
14
- const headers = new Set(items);
15
- headers.add('Cache-Control');
16
- headers.add('Last-Event-ID');
17
- context.response.headers.set('Access-Control-Allow-Headers', [...headers].join(', '));
18
31
  }
package/api/utils.d.ts CHANGED
@@ -5,6 +5,5 @@ type GetApiEndpointUrlData = {
5
5
  defaultPrefix: string | undefined | null;
6
6
  explicitVersion?: number | null;
7
7
  };
8
- export declare const defaultAccessControlAllowHeaders = "Content-Type, Authorization";
9
8
  export declare function getFullApiEndpointResource({ api, endpoint, defaultPrefix, explicitVersion }: GetApiEndpointUrlData): string;
10
9
  export {};
package/api/utils.js CHANGED
@@ -2,7 +2,6 @@ import { toArray } from '../utils/array/array.js';
2
2
  import { compareByValueDescending } from '../utils/comparison.js';
3
3
  import { sort } from '../utils/iterable-helpers/sort.js';
4
4
  import { isDefined, isNull } from '../utils/type-guards.js';
5
- export const defaultAccessControlAllowHeaders = 'Content-Type, Authorization';
6
5
  export function getFullApiEndpointResource({ api, endpoint, defaultPrefix, explicitVersion }) {
7
6
  const versionArray = toArray(isDefined(explicitVersion) ? explicitVersion : endpoint.version);
8
7
  const version = sort(versionArray, compareByValueDescending)[0];
@@ -15,6 +15,7 @@ export declare const notificationApiDefinition: {
15
15
  credentials: true;
16
16
  dataStream: {
17
17
  idProvider: () => string;
18
+ heartbeatInterval: number;
18
19
  };
19
20
  };
20
21
  types: {
@@ -131,6 +132,7 @@ declare const _NotificationApiClient: import("../../api/client/index.js").ApiCli
131
132
  credentials: true;
132
133
  dataStream: {
133
134
  idProvider: () => string;
135
+ heartbeatInterval: number;
134
136
  };
135
137
  };
136
138
  types: {
@@ -22,6 +22,7 @@ export const notificationApiDefinition = defineApi({
22
22
  credentials: true,
23
23
  dataStream: {
24
24
  idProvider: () => currentTimestamp().toString(),
25
+ heartbeatInterval: 15000,
25
26
  },
26
27
  },
27
28
  types: {
@@ -21,7 +21,8 @@ let NotificationApiController = class NotificationApiController {
21
21
  const token = await getToken();
22
22
  const source = this.sseService.register(token.payload.tenant, token.payload.subject);
23
23
  const asyncIterable = toAsyncIterable(source);
24
- abortSignal.addEventListener('abort', () => this.sseService.unregister(token.payload.tenant, token.payload.subject, source));
24
+ const abortHandler = () => this.sseService.unregister(token.payload.tenant, token.payload.subject, source);
25
+ abortSignal.addEventListener('abort', abortHandler, { once: true });
25
26
  try {
26
27
  if (isDefined(lastEventId)) {
27
28
  const lastEventIdNumber = Number(lastEventId);
@@ -41,6 +42,7 @@ let NotificationApiController = class NotificationApiController {
41
42
  yield* asyncIterable;
42
43
  }
43
44
  finally {
45
+ abortSignal.removeEventListener('abort', abortHandler);
44
46
  this.sseService.unregister(token.payload.tenant, token.payload.subject, source);
45
47
  }
46
48
  }
@@ -14,9 +14,10 @@ type NotificationBusMessage = {
14
14
  unreadCount?: number;
15
15
  };
16
16
  export type NotificationBusMessageData = TypedOmit<NotificationBusMessage, 'tenantId' | 'userId'>;
17
- export declare class NotificationSseService {
17
+ export declare class NotificationSseService implements Disposable {
18
18
  #private;
19
19
  [afterResolve](): void;
20
+ [Symbol.dispose](): void;
20
21
  register(tenantId: string, userId: string): Subject<NotificationStreamItem>;
21
22
  unregister(tenantId: string, userId: string, source: Subject<NotificationStreamItem>): void;
22
23
  dispatch(tenantId: string, userId: string, data: NotificationBusMessageData): Promise<void>;
@@ -7,6 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { Subject } from 'rxjs';
8
8
  import { afterResolve, inject, Singleton } from '../../../injector/index.js';
9
9
  import { MessageBusProvider } from '../../../message-bus/index.js';
10
+ import { isDefined, isUndefined } from '../../../utils/type-guards.js';
10
11
  let NotificationSseService = class NotificationSseService {
11
12
  #messageBusProvider = inject(MessageBusProvider);
12
13
  #messageBus = this.#messageBusProvider.get('notification');
@@ -14,6 +15,16 @@ let NotificationSseService = class NotificationSseService {
14
15
  [afterResolve]() {
15
16
  this.#messageBus.allMessages$.subscribe((message) => this.dispatchToLocal(message));
16
17
  }
18
+ [Symbol.dispose]() {
19
+ for (const tenantMap of this.#sources.values()) {
20
+ for (const userSources of tenantMap.values()) {
21
+ for (const source of userSources) {
22
+ source.complete();
23
+ }
24
+ }
25
+ }
26
+ this.#sources.clear();
27
+ }
17
28
  register(tenantId, userId) {
18
29
  const source = new Subject();
19
30
  let tenantMap = this.#sources.get(tenantId);
@@ -31,15 +42,17 @@ let NotificationSseService = class NotificationSseService {
31
42
  }
32
43
  unregister(tenantId, userId, source) {
33
44
  const tenantMap = this.#sources.get(tenantId);
34
- const userSources = tenantMap?.get(userId);
35
- if (userSources) {
36
- userSources.delete(source);
37
- if (userSources.size == 0) {
38
- tenantMap?.delete(userId);
45
+ if (isDefined(tenantMap)) {
46
+ const userSources = tenantMap.get(userId);
47
+ if (isDefined(userSources)) {
48
+ userSources.delete(source);
49
+ if (userSources.size == 0) {
50
+ tenantMap.delete(userId);
51
+ }
52
+ }
53
+ if (tenantMap.size == 0) {
54
+ this.#sources.delete(tenantId);
39
55
  }
40
- }
41
- if (tenantMap?.size == 0) {
42
- this.#sources.delete(tenantId);
43
56
  }
44
57
  source.complete();
45
58
  }
@@ -53,17 +66,19 @@ let NotificationSseService = class NotificationSseService {
53
66
  dispatchToLocal(message) {
54
67
  const tenantMap = this.#sources.get(message.tenantId);
55
68
  const userSources = tenantMap?.get(message.userId);
56
- if (userSources != null) {
57
- for (const source of userSources) {
58
- source.next({
59
- notification: message.notification,
60
- readId: message.readId,
61
- readAll: message.readAll,
62
- archiveId: message.archiveId,
63
- archiveAll: message.archiveAll,
64
- unreadCount: message.unreadCount,
65
- });
66
- }
69
+ if (isUndefined(userSources)) {
70
+ return;
71
+ }
72
+ const item = {
73
+ notification: message.notification,
74
+ readId: message.readId,
75
+ readAll: message.readAll,
76
+ archiveId: message.archiveId,
77
+ archiveAll: message.archiveAll,
78
+ unreadCount: message.unreadCount,
79
+ };
80
+ for (const source of userSources) {
81
+ source.next(item);
67
82
  }
68
83
  }
69
84
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.171",
3
+ "version": "0.93.172",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,3 +1,4 @@
1
+ import type { Signal } from '../signals/index.js';
1
2
  import type { UndefinableJson } from '../types/types.js';
2
3
  import type { AnyIterable } from '../utils/any-iterable-iterator.js';
3
4
  import { ServerSentEventsSource } from './server-sent-events-source.js';
@@ -9,14 +10,18 @@ export type DataStreamSourceOptions<T> = {
9
10
  * For notification-like streams, where only new items are sent, delta should usually be disabled.
10
11
  */
11
12
  delta?: boolean;
13
+ /** Interval in milliseconds to send a heartbeat (SSE comment). */
14
+ heartbeatInterval?: number;
15
+ /** Initial retry recommendation for consumers in milliseconds. */
16
+ retry?: number;
12
17
  errorFormatter?: DataStreamErrorFormatter;
13
18
  idProvider?: (data: T) => string | undefined;
14
19
  };
15
20
  export declare class DataStreamSource<T> {
16
21
  #private;
17
22
  readonly eventSource: ServerSentEventsSource;
18
- readonly closed: import("../signals/api.js").Signal<boolean>;
19
- constructor({ delta, errorFormatter, idProvider }?: DataStreamSourceOptions<T>);
23
+ readonly closed: Signal<boolean>;
24
+ constructor(options?: DataStreamSourceOptions<T>);
20
25
  static fromIterable<T>(iterable: AnyIterable<T>, options?: DataStreamSourceOptions<T>): DataStreamSource<T>;
21
26
  send(data: T, id?: string): Promise<void>;
22
27
  close(): Promise<void>;
@@ -17,13 +17,16 @@ export class DataStreamSource {
17
17
  #useDelta;
18
18
  #errorFormatter;
19
19
  #idProvider;
20
- eventSource = new ServerSentEventsSource();
21
- closed = this.eventSource.closed;
20
+ eventSource;
21
+ closed;
22
22
  #lastData;
23
- constructor({ delta = true, errorFormatter, idProvider } = {}) {
23
+ constructor(options = {}) {
24
+ const { delta = true, errorFormatter, idProvider, heartbeatInterval, retry } = options;
24
25
  this.#useDelta = delta;
25
26
  this.#errorFormatter = errorFormatter ?? defaultErrorFormatter;
26
27
  this.#idProvider = idProvider;
28
+ this.eventSource = new ServerSentEventsSource({ heartbeatInterval, retry });
29
+ this.closed = this.eventSource.closed;
27
30
  }
28
31
  static fromIterable(iterable, options) {
29
32
  const source = new DataStreamSource(options);
@@ -1,10 +1,16 @@
1
1
  import type { ServerSentJsonEvent, ServerSentTextEvent } from './model.js';
2
+ export type ServerSentEventsSourceOptions = {
3
+ /** Interval in milliseconds to send a heartbeat (SSE comment). */
4
+ heartbeatInterval?: number;
5
+ /** Initial retry recommendation for consumers in milliseconds. */
6
+ retry?: number;
7
+ };
2
8
  export declare class ServerSentEventsSource {
3
9
  #private;
4
10
  readonly readable: ReadableStream<string>;
5
11
  readonly closed: import("../signals/api.js").Signal<boolean>;
6
12
  readonly error: import("../signals/api.js").Signal<Error | undefined>;
7
- constructor();
13
+ constructor(options?: ServerSentEventsSourceOptions);
8
14
  close(): Promise<void>;
9
15
  sendComment(comment: string): Promise<void>;
10
16
  sendText({ name, data, id, retry }: ServerSentTextEvent): Promise<void>;
@@ -8,17 +8,35 @@ export class ServerSentEventsSource {
8
8
  readable;
9
9
  closed = this.#closed.asReadonly();
10
10
  error = this.#error.asReadonly();
11
- constructor() {
11
+ constructor(options) {
12
12
  const { writable, readable } = new TransformStream();
13
13
  this.#writable = writable;
14
14
  this.readable = readable;
15
15
  this.#writer = this.#writable.getWriter();
16
+ let interval;
16
17
  this.#writer.closed
17
- .then(() => (this.#closed.set(true)))
18
+ .then(() => {
19
+ this.#closed.set(true);
20
+ clearInterval(interval);
21
+ })
18
22
  .catch((error) => {
19
23
  this.#error.set(error);
20
24
  this.#closed.set(true);
25
+ clearInterval(interval);
21
26
  });
27
+ if (isDefined(options?.retry)) {
28
+ void this.sendText({ retry: options.retry });
29
+ }
30
+ if (isDefined(options?.heartbeatInterval)) {
31
+ interval = setInterval(() => {
32
+ if (this.#closed()) {
33
+ clearInterval(interval);
34
+ return;
35
+ }
36
+ void this.sendComment('').catch(() => { });
37
+ }, options.heartbeatInterval);
38
+ interval.unref?.();
39
+ }
22
40
  }
23
41
  async close() {
24
42
  await this.#writer.close();