@tstdl/base 0.93.170 → 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];
@@ -1,7 +1,9 @@
1
1
  import type { ModelReference } from 'genkit';
2
2
  import type { Instructions } from '../../ai/prompts/instructions-formatter.js';
3
3
  import type { DocumentWorkflowStep } from './document-workflow.model.js';
4
+ import type { GeminiModelConfig } from '../../ai/index.js';
4
5
  export type InstructionStrategy = 'replace' | 'append';
6
+ export type DocumentManagementThinkingLevel = NonNullable<GeminiModelConfig['thinkingConfig']>['thinkingLevel'];
5
7
  export type InstructionOverride = string | {
6
8
  /**
7
9
  * Simple way: Provide a specific format pattern.
@@ -20,6 +22,8 @@ export type InstructionOverride = string | {
20
22
  export type AiConfiguration = {
21
23
  /** The model to use. */
22
24
  model?: ModelReference<any>;
25
+ /** The thinking level to use for thinking models. */
26
+ thinkingLevel?: DocumentManagementThinkingLevel;
23
27
  /** The language to use for AI outputs. */
24
28
  language?: string;
25
29
  /** Prompt overrides. */
@@ -2,7 +2,7 @@ import { type TstdlGenkitGenerationOptions } from '../../../ai/genkit/index.js';
2
2
  import { type PromptBuilder } from '../../../ai/prompts/index.js';
3
3
  import { type SchemaTestable } from '../../../schema/index.js';
4
4
  import type { ObjectLiteral } from '../../../types/types.js';
5
- import type { ModelReference } from 'genkit';
5
+ import { type ModelReference } from 'genkit';
6
6
  import type { AiConfiguration, DocumentPropertyDataType, InstructionOverride } from '../../models/index.js';
7
7
  import { Document, DocumentWorkflowStep } from '../../models/index.js';
8
8
  import { type AiConfigurationResolveDataMap } from './document-management-ai-provider.service.js';
@@ -19,8 +19,8 @@ import { distinct } from '../../../utils/array/index.js';
19
19
  import { numericDateToDateTime, tryDateObjectToNumericDate } from '../../../utils/date-time.js';
20
20
  import { fromEntries, hasOwnProperty, objectEntries, objectKeys } from '../../../utils/object/object.js';
21
21
  import { assertDefined, assertDefinedPass, assertNotNull, isDefined, isNotNull, isNotNullOrUndefined, isNull, isString, isUndefined } from '../../../utils/type-guards.js';
22
+ import { GenkitError } from 'genkit';
22
23
  import { Document, DocumentProperty, DocumentRequestState, DocumentType, DocumentTypeProperty, DocumentWorkflowStep } from '../../models/index.js';
23
- import { DocumentManagementConfiguration } from '../module.js';
24
24
  import { documentCategory, documentRequest, documentRequestCollectionAssignment, documentType } from '../schemas.js';
25
25
  import { DocumentCategoryTypeService } from './document-category-type.service.js';
26
26
  import { DocumentCollectionService } from './document-collection.service.js';
@@ -41,7 +41,6 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
41
41
  #documentCategoryTypeService = inject(DocumentCategoryTypeService);
42
42
  #documentFileService = inject(DocumentFileService);
43
43
  #documentPropertyService = inject(DocumentPropertyService);
44
- #documentManagementConfiguration = inject(DocumentManagementConfiguration, undefined, { optional: true });
45
44
  #aiProvider = inject(DocumentManagementAiProviderService, undefined, { optional: true });
46
45
  #documentPropertyRepository = injectRepository(DocumentProperty);
47
46
  #documentRepository = injectRepository(Document);
@@ -83,7 +82,7 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
83
82
  promptBuilder,
84
83
  schema,
85
84
  document,
86
- config: { maxOutputTokens: 128 },
85
+ config: { maxOutputTokens: 1024 },
87
86
  aiConfig,
88
87
  });
89
88
  const typeId = typeLabelEntries.find((entry) => entry.label == result.documentType)?.id;
@@ -149,7 +148,7 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
149
148
  data: { existingTags: tagLabels },
150
149
  schema: generationSchema,
151
150
  document,
152
- config: { maxOutputTokens: 2048 },
151
+ config: { maxOutputTokens: 4096 },
153
152
  aiConfig,
154
153
  });
155
154
  const filteredDocumentTags = extraction.documentTags.filter((tag) => (tag != extraction.documentTitle) && (tag != extraction.documentSubtitle));
@@ -207,7 +206,7 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
207
206
  promptBuilder: createAssignCollectionPrompt(),
208
207
  data: { document: documentData, documentProperties: fromEntries(propertyEntries), collections },
209
208
  schema: assignCollectionSchema,
210
- config: { maxOutputTokens: 512 },
209
+ config: { maxOutputTokens: 2048 },
211
210
  });
212
211
  return result.collectionIds;
213
212
  }
@@ -253,13 +252,15 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
253
252
  promptBuilder: createAssignRequestPrompt(),
254
253
  data: { document: documentData, documentProperties: fromEntries(propertyEntries), requests },
255
254
  schema: assignRequestSchema,
256
- config: { maxOutputTokens: 128 },
255
+ config: { maxOutputTokens: 1024 },
257
256
  });
258
257
  return result.requestId;
259
258
  }
260
259
  async runAi(tenantId, step, stepData, options) {
261
260
  const config = options.aiConfig ?? await this.resolveAiConfiguration(tenantId, step, stepData);
262
261
  const model = config.model ?? options.defaultModel;
262
+ const thinkingLevel = config.thinkingLevel ?? 'LOW';
263
+ const configuredModel = isString(model) ? model : model.withConfig({ thinkingConfig: { thinkingLevel } });
263
264
  const builder = options.promptBuilder;
264
265
  if (isDefined(config.language)) {
265
266
  builder.addInstructions({ 'Output Language': languagePrompt(config.language) });
@@ -282,17 +283,26 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
282
283
  if (isDefined(options.document)) {
283
284
  builder.addMedia(await this.#documentFileService.getContent(options.document), options.document.mimeType);
284
285
  }
285
- const result = await this.#genkit.generate(genkitGenerationOptions({
286
- model,
287
- config: options.config,
288
- output: { schema: options.schema },
289
- system: builder.buildSystemPrompt(),
290
- prompt: builder.buildUserPrompt(),
291
- }));
292
- if (isNull(result.output)) {
293
- throw new Error(`AI returned null output for ${step} ${options.targetId ?? ''}`);
286
+ try {
287
+ const result = await this.#genkit.generate(genkitGenerationOptions({
288
+ model: configuredModel,
289
+ config: options.config,
290
+ output: { schema: options.schema },
291
+ system: builder.buildSystemPrompt(),
292
+ prompt: builder.buildUserPrompt(),
293
+ }));
294
+ this.#logger.trace(`AI result for ${step} ${options.targetId ?? ''}: usage=${JSON.stringify(result.usage)}, finishReason=${result.finishReason}`);
295
+ if (isNull(result.output)) {
296
+ throw new Error(`AI returned null output for ${step} ${options.targetId ?? ''}. Finish reason: ${result.finishReason}`);
297
+ }
298
+ return result.output;
299
+ }
300
+ catch (error) {
301
+ if (error instanceof GenkitError) {
302
+ this.#logger.error(`AI ${step} ${options.targetId ?? ''} failed: status=${error.status}, message=${error.originalMessage}, details=${JSON.stringify(error.detail)}`);
303
+ }
304
+ throw error;
294
305
  }
295
- return result.output;
296
306
  }
297
307
  async resolveAiConfiguration(tenantId, step, data) {
298
308
  const globalConfig = await this.#aiProvider?.getGlobalConfiguration(tenantId);
@@ -312,6 +322,7 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
312
322
  stepConfig,
313
323
  ].filter(isDefined);
314
324
  const model = mergeMostSpecific(configs, 'model');
325
+ const thinkingLevel = mergeMostSpecific(configs, 'thinkingLevel');
315
326
  const language = mergeMostSpecific(configs, 'language');
316
327
  const systemAddition = resolveAdditions(configs.map((c) => c.prompt?.systemAddition));
317
328
  const userAddition = resolveAdditions(configs.map((c) => c.prompt?.userAddition));
@@ -321,6 +332,7 @@ let DocumentManagementAiService = DocumentManagementAiService_1 = class Document
321
332
  const classification = mergeMostSpecific(configs, 'classification');
322
333
  return {
323
334
  model,
335
+ thinkingLevel,
324
336
  language,
325
337
  prompt: {
326
338
  systemAddition,
@@ -1,6 +1,7 @@
1
1
  import type { AiConfiguration, DocumentManagementAiConfiguration, DocumentWorkflowStep } from '../../document-management/index.js';
2
2
  import { DocumentManagementAiProviderService, type AiConfigurationResolveDataMap } from '../../document-management/server/index.js';
3
3
  export declare class ExampleAiProviderService extends DocumentManagementAiProviderService {
4
+ #private;
4
5
  /**
5
6
  * Providing global defaults for the entire Document Management module.
6
7
  */
@@ -9,6 +9,7 @@ import { fewShotPrompt, stylePrompt } from '../../ai/prompts/index.js';
9
9
  import { DocumentManagementAiProviderService } from '../../document-management/server/index.js';
10
10
  import { Singleton } from '../../injector/index.js';
11
11
  let ExampleAiProviderService = class ExampleAiProviderService extends DocumentManagementAiProviderService {
12
+ #model = injectModel('gemini-3.1-flash-lite-preview').withConfig({ thinkingConfig: { thinkingLevel: 'LOW' } });
12
13
  /**
13
14
  * Providing global defaults for the entire Document Management module.
14
15
  */
@@ -16,9 +17,9 @@ let ExampleAiProviderService = class ExampleAiProviderService extends DocumentMa
16
17
  return {
17
18
  defaults: {
18
19
  language: 'German', // Steer all AI outputs to German
19
- model: injectModel('gemini-3.1-flash-lite-preview').withConfig({ thinkingConfig: { thinkingLevel: 'LOW' } }),
20
+ model: this.#model,
20
21
  prompt: {
21
- systemAddition: 'Additional global instructions for all AI steps.',
22
+ // systemAddition: 'Additional global instructions for all AI steps.',
22
23
  },
23
24
  },
24
25
  };
@@ -32,9 +33,9 @@ let ExampleAiProviderService = class ExampleAiProviderService extends DocumentMa
32
33
  getClassificationConfiguration() {
33
34
  return {
34
35
  // Use a faster/cheaper model for classification
35
- model: injectModel('gemini-3.1-flash-lite-preview').withConfig({ thinkingConfig: { thinkingLevel: 'LOW' } }),
36
+ model: this.#model,
36
37
  prompt: {
37
- systemAddition: 'For classification, focus strictly on the layout.',
38
+ systemAddition: 'For classification, focus strictly on document content.',
38
39
  },
39
40
  };
40
41
  }
@@ -49,7 +49,7 @@ const config = {
49
49
  },
50
50
  },
51
51
  s3: {
52
- endpoint: string('S3_ENDPOINT', 'http://localhost:9000'),
52
+ endpoint: string('S3_ENDPOINT', 'http://localhost:19552'),
53
53
  accessKey: string('S3_ACCESS_KEY', 'tstdl-dev'),
54
54
  secretKey: string('S3_SECRET_KEY', 'tstdl-dev'),
55
55
  bucket: string('S3_BUCKET', undefined),
@@ -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.170",
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();