@animalabs/membrane 0.5.24 → 0.5.25

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.
Files changed (38) hide show
  1. package/dist/membrane.d.ts +37 -0
  2. package/dist/membrane.d.ts.map +1 -1
  3. package/dist/membrane.js +554 -0
  4. package/dist/membrane.js.map +1 -1
  5. package/dist/providers/mock.d.ts +8 -0
  6. package/dist/providers/mock.d.ts.map +1 -1
  7. package/dist/providers/mock.js +39 -2
  8. package/dist/providers/mock.js.map +1 -1
  9. package/dist/providers/openai-compatible.d.ts.map +1 -1
  10. package/dist/providers/openai-compatible.js +5 -1
  11. package/dist/providers/openai-compatible.js.map +1 -1
  12. package/dist/providers/openai.d.ts.map +1 -1
  13. package/dist/providers/openai.js +5 -1
  14. package/dist/providers/openai.js.map +1 -1
  15. package/dist/providers/openrouter.d.ts.map +1 -1
  16. package/dist/providers/openrouter.js +5 -1
  17. package/dist/providers/openrouter.js.map +1 -1
  18. package/dist/types/index.d.ts +2 -0
  19. package/dist/types/index.d.ts.map +1 -1
  20. package/dist/types/index.js +1 -0
  21. package/dist/types/index.js.map +1 -1
  22. package/dist/types/yielding-stream.d.ts +167 -0
  23. package/dist/types/yielding-stream.d.ts.map +1 -0
  24. package/dist/types/yielding-stream.js +34 -0
  25. package/dist/types/yielding-stream.js.map +1 -0
  26. package/dist/yielding-stream.d.ts +60 -0
  27. package/dist/yielding-stream.d.ts.map +1 -0
  28. package/dist/yielding-stream.js +204 -0
  29. package/dist/yielding-stream.js.map +1 -0
  30. package/package.json +1 -1
  31. package/src/membrane.ts +691 -1
  32. package/src/providers/mock.ts +47 -2
  33. package/src/providers/openai-compatible.ts +8 -3
  34. package/src/providers/openai.ts +8 -3
  35. package/src/providers/openrouter.ts +8 -3
  36. package/src/types/index.ts +23 -0
  37. package/src/types/yielding-stream.ts +228 -0
  38. package/src/yielding-stream.ts +271 -0
@@ -12,6 +12,7 @@ import type {
12
12
  ProviderResponse,
13
13
  StreamCallbacks,
14
14
  } from '../types/provider.js';
15
+ import { abortError } from '../types/errors.js';
15
16
 
16
17
  export interface MockAdapterConfig {
17
18
  /** Default response text when no specific response is configured */
@@ -157,12 +158,15 @@ export class MockAdapter implements ProviderAdapter {
157
158
  request: ProviderRequest,
158
159
  options?: ProviderRequestOptions
159
160
  ): Promise<ProviderResponse> {
161
+ // Check for abort before starting
162
+ this.checkAbort(options?.signal);
163
+
160
164
  // Call onRequest callback if provided
161
165
  options?.onRequest?.(request);
162
166
 
163
167
  // Simulate processing delay
164
168
  if (this.config.completeDelayMs > 0) {
165
- await this.sleep(this.config.completeDelayMs);
169
+ await this.abortableSleep(this.config.completeDelayMs, options?.signal);
166
170
  }
167
171
 
168
172
  const responseText = this.getResponse(request);
@@ -185,20 +189,28 @@ export class MockAdapter implements ProviderAdapter {
185
189
  callbacks: StreamCallbacks,
186
190
  options?: ProviderRequestOptions
187
191
  ): Promise<ProviderResponse> {
192
+ // Check for abort before starting
193
+ this.checkAbort(options?.signal);
194
+
188
195
  // Call onRequest callback if provided
189
196
  options?.onRequest?.(request);
190
197
 
191
198
  const responseText = this.getResponse(request);
199
+ let streamedText = '';
192
200
 
193
201
  // Stream the response in chunks
194
202
  let offset = 0;
195
203
  while (offset < responseText.length) {
204
+ // Check for abort before each chunk
205
+ this.checkAbort(options?.signal);
206
+
196
207
  const chunk = responseText.slice(offset, offset + this.config.streamChunkSize);
197
208
  callbacks.onChunk(chunk);
209
+ streamedText += chunk;
198
210
  offset += this.config.streamChunkSize;
199
211
 
200
212
  if (offset < responseText.length && this.config.streamChunkDelayMs > 0) {
201
- await this.sleep(this.config.streamChunkDelayMs);
213
+ await this.abortableSleep(this.config.streamChunkDelayMs, options?.signal);
202
214
  }
203
215
  }
204
216
 
@@ -215,6 +227,39 @@ export class MockAdapter implements ProviderAdapter {
215
227
  };
216
228
  }
217
229
 
230
+ /**
231
+ * Check if the abort signal is set and throw if so.
232
+ */
233
+ private checkAbort(signal?: AbortSignal): void {
234
+ if (signal?.aborted) {
235
+ throw abortError('Request aborted');
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Sleep that can be interrupted by an abort signal.
241
+ */
242
+ private abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
243
+ return new Promise((resolve, reject) => {
244
+ if (signal?.aborted) {
245
+ reject(abortError('Request aborted'));
246
+ return;
247
+ }
248
+
249
+ const onAbort = () => {
250
+ clearTimeout(timeout);
251
+ reject(abortError('Request aborted'));
252
+ };
253
+
254
+ const timeout = setTimeout(() => {
255
+ signal?.removeEventListener('abort', onAbort);
256
+ resolve();
257
+ }, ms);
258
+
259
+ signal?.addEventListener('abort', onAbort, { once: true });
260
+ });
261
+ }
262
+
218
263
  private sleep(ms: number): Promise<void> {
219
264
  return new Promise(resolve => setTimeout(resolve, ms));
220
265
  }
@@ -350,16 +350,21 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
350
350
  return toolResults;
351
351
  }
352
352
 
353
+ // Skip messages with no usable content (image-only, embed-only messages)
354
+ if (textParts.length === 0 && toolCalls.length === 0) {
355
+ return [];
356
+ }
357
+
353
358
  // Otherwise build normal message
354
359
  const result: OpenAIMessage = {
355
360
  role: msg.role,
356
- content: textParts.join('\n') || null,
361
+ content: textParts.length > 0 ? textParts.join('\n') : null,
357
362
  };
358
-
363
+
359
364
  if (toolCalls.length > 0) {
360
365
  result.tool_calls = toolCalls;
361
366
  }
362
-
367
+
363
368
  return [result];
364
369
  }
365
370
 
@@ -449,16 +449,21 @@ export class OpenAIAdapter implements ProviderAdapter {
449
449
  return toolResults;
450
450
  }
451
451
 
452
+ // Skip messages with no usable content (image-only, embed-only messages)
453
+ if (textParts.length === 0 && toolCalls.length === 0) {
454
+ return [];
455
+ }
456
+
452
457
  // Otherwise build normal message
453
458
  const result: OpenAIMessage = {
454
459
  role: msg.role,
455
- content: textParts.join('\n') || null,
460
+ content: textParts.length > 0 ? textParts.join('\n') : null,
456
461
  };
457
-
462
+
458
463
  if (toolCalls.length > 0) {
459
464
  result.tool_calls = toolCalls;
460
465
  }
461
-
466
+
462
467
  return [result];
463
468
  }
464
469
 
@@ -407,17 +407,22 @@ export class OpenRouterAdapter implements ProviderAdapter {
407
407
  return toolResults;
408
408
  }
409
409
 
410
+ // Skip messages with no usable content (image-only, embed-only messages)
411
+ if (textParts.length === 0 && toolCalls.length === 0) {
412
+ return [];
413
+ }
414
+
410
415
  // Otherwise build normal message
411
416
  const result: OpenRouterMessage = {
412
417
  role: msg.role,
413
418
  // Use content blocks array if caching is in use, otherwise concatenate text
414
- content: hasCache ? contentBlocks : (textParts.join('\n') || null),
419
+ content: hasCache ? contentBlocks : (textParts.length > 0 ? textParts.join('\n') : null),
415
420
  };
416
-
421
+
417
422
  if (toolCalls.length > 0) {
418
423
  result.tool_calls = toolCalls;
419
424
  }
420
-
425
+
421
426
  return [result];
422
427
  }
423
428
 
@@ -120,6 +120,29 @@ export type {
120
120
  BlockDelta,
121
121
  } from './streaming.js';
122
122
 
123
+ // Yielding Stream
124
+ export type {
125
+ TokensEvent,
126
+ StreamBlockEvent,
127
+ ToolCallsEvent,
128
+ UsageEvent,
129
+ CompleteEvent,
130
+ ErrorEvent,
131
+ AbortedEvent,
132
+ StreamEvent,
133
+ YieldingStream,
134
+ YieldingStreamOptions,
135
+ } from './yielding-stream.js';
136
+
137
+ export {
138
+ isTokensEvent,
139
+ isToolCallsEvent,
140
+ isCompleteEvent,
141
+ isErrorEvent,
142
+ isAbortedEvent,
143
+ isTerminalEvent,
144
+ } from './yielding-stream.js';
145
+
123
146
  // Errors
124
147
  export type {
125
148
  MembraneErrorType,
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Yielding stream types for membrane
3
+ *
4
+ * This module defines the interface for a streaming API that yields control
5
+ * back to the caller when tool calls are detected, rather than handling them
6
+ * internally via callbacks.
7
+ *
8
+ * @see agent-framework/docs/yielding-stream-architecture.md
9
+ */
10
+
11
+ import type { ContentBlock } from './content.js';
12
+ import type { ToolCall, ToolResult, ToolContext } from './tools.js';
13
+ import type { BasicUsage, NormalizedResponse, StopReason } from './response.js';
14
+ import type { ChunkMeta, BlockEvent } from './streaming.js';
15
+
16
+ // ============================================================================
17
+ // Stream Events
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Token/chunk event - raw text as it arrives from the LLM.
22
+ */
23
+ export interface TokensEvent {
24
+ type: 'tokens';
25
+ content: string;
26
+ meta: ChunkMeta;
27
+ }
28
+
29
+ /**
30
+ * Block event - structural block start/complete notifications.
31
+ */
32
+ export interface StreamBlockEvent {
33
+ type: 'block';
34
+ event: BlockEvent;
35
+ }
36
+
37
+ /**
38
+ * Tool calls event - LLM has requested tool execution.
39
+ * The stream pauses here until results are provided via provideToolResults().
40
+ */
41
+ export interface ToolCallsEvent {
42
+ type: 'tool-calls';
43
+ calls: ToolCall[];
44
+ context: ToolContext;
45
+ }
46
+
47
+ /**
48
+ * Usage update event - token counts updated.
49
+ */
50
+ export interface UsageEvent {
51
+ type: 'usage';
52
+ usage: BasicUsage;
53
+ }
54
+
55
+ /**
56
+ * Complete event - inference cycle finished successfully.
57
+ */
58
+ export interface CompleteEvent {
59
+ type: 'complete';
60
+ response: NormalizedResponse;
61
+ }
62
+
63
+ /**
64
+ * Error event - something went wrong.
65
+ */
66
+ export interface ErrorEvent {
67
+ type: 'error';
68
+ error: Error;
69
+ }
70
+
71
+ /**
72
+ * Aborted event - stream was cancelled.
73
+ */
74
+ export interface AbortedEvent {
75
+ type: 'aborted';
76
+ reason: 'user' | 'timeout' | 'error';
77
+ partialContent?: ContentBlock[];
78
+ rawAssistantText?: string;
79
+ toolCalls?: ToolCall[];
80
+ toolResults?: ToolResult[];
81
+ }
82
+
83
+ /**
84
+ * Union of all stream events.
85
+ */
86
+ export type StreamEvent =
87
+ | TokensEvent
88
+ | StreamBlockEvent
89
+ | ToolCallsEvent
90
+ | UsageEvent
91
+ | CompleteEvent
92
+ | ErrorEvent
93
+ | AbortedEvent;
94
+
95
+ // ============================================================================
96
+ // Yielding Stream Interface
97
+ // ============================================================================
98
+
99
+ /**
100
+ * A streaming inference that yields control to the caller for tool execution.
101
+ *
102
+ * Usage:
103
+ * ```typescript
104
+ * const stream = membrane.streamYielding(request, options);
105
+ *
106
+ * for await (const event of stream) {
107
+ * switch (event.type) {
108
+ * case 'tokens':
109
+ * process.stdout.write(event.content);
110
+ * break;
111
+ * case 'tool-calls':
112
+ * const results = await executeTools(event.calls);
113
+ * stream.provideToolResults(results);
114
+ * break;
115
+ * case 'complete':
116
+ * console.log('Done:', event.response);
117
+ * break;
118
+ * case 'error':
119
+ * console.error('Error:', event.error);
120
+ * break;
121
+ * }
122
+ * }
123
+ * ```
124
+ */
125
+ export interface YieldingStream extends AsyncIterable<StreamEvent> {
126
+ /**
127
+ * Provide tool results after receiving a 'tool-calls' event.
128
+ * The stream will resume and continue generating.
129
+ *
130
+ * @param results - Results for the tool calls (must match call IDs)
131
+ * @throws Error if called when not waiting for tool results
132
+ */
133
+ provideToolResults(results: ToolResult[]): void;
134
+
135
+ /**
136
+ * Cancel the stream. Any in-flight requests will be aborted.
137
+ * The iterator will yield an 'aborted' event and then complete.
138
+ */
139
+ cancel(): void;
140
+
141
+ /**
142
+ * Check if the stream is currently waiting for tool results.
143
+ */
144
+ readonly isWaitingForTools: boolean;
145
+
146
+ /**
147
+ * Get the IDs of tool calls we're waiting for results for.
148
+ * Empty if not waiting for tools.
149
+ */
150
+ readonly pendingToolCallIds: string[];
151
+
152
+ /**
153
+ * Current tool execution depth (0 = first inference, 1 = after first tool round, etc.)
154
+ */
155
+ readonly toolDepth: number;
156
+ }
157
+
158
+ // ============================================================================
159
+ // Yielding Stream Options
160
+ // ============================================================================
161
+
162
+ /**
163
+ * Options for streamYielding().
164
+ * Simpler than StreamOptions since tool execution is handled externally.
165
+ */
166
+ export interface YieldingStreamOptions {
167
+ /** Abort signal for cancellation */
168
+ signal?: AbortSignal;
169
+
170
+ /** Request timeout (per API call, not total) */
171
+ timeoutMs?: number;
172
+
173
+ /** Request ID for correlation/logging */
174
+ requestId?: string;
175
+
176
+ /** Maximum tool execution depth (default: 10) */
177
+ maxToolDepth?: number;
178
+
179
+ /**
180
+ * Whether to emit 'tokens' events.
181
+ * Set to false if you only care about tool calls and final response.
182
+ * Default: true
183
+ */
184
+ emitTokens?: boolean;
185
+
186
+ /**
187
+ * Whether to emit 'block' events.
188
+ * Default: true
189
+ */
190
+ emitBlocks?: boolean;
191
+
192
+ /**
193
+ * Whether to emit 'usage' events.
194
+ * Default: true
195
+ */
196
+ emitUsage?: boolean;
197
+ }
198
+
199
+ // ============================================================================
200
+ // Type Guards
201
+ // ============================================================================
202
+
203
+ export function isTokensEvent(event: StreamEvent): event is TokensEvent {
204
+ return event.type === 'tokens';
205
+ }
206
+
207
+ export function isToolCallsEvent(event: StreamEvent): event is ToolCallsEvent {
208
+ return event.type === 'tool-calls';
209
+ }
210
+
211
+ export function isCompleteEvent(event: StreamEvent): event is CompleteEvent {
212
+ return event.type === 'complete';
213
+ }
214
+
215
+ export function isErrorEvent(event: StreamEvent): event is ErrorEvent {
216
+ return event.type === 'error';
217
+ }
218
+
219
+ export function isAbortedEvent(event: StreamEvent): event is AbortedEvent {
220
+ return event.type === 'aborted';
221
+ }
222
+
223
+ /**
224
+ * Check if the stream has terminated (complete, error, or aborted).
225
+ */
226
+ export function isTerminalEvent(event: StreamEvent): event is CompleteEvent | ErrorEvent | AbortedEvent {
227
+ return event.type === 'complete' || event.type === 'error' || event.type === 'aborted';
228
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * YieldingStream implementation
3
+ *
4
+ * Provides an async iterator interface for streaming inference that yields
5
+ * control back to the caller for tool execution.
6
+ */
7
+
8
+ import type {
9
+ StreamEvent,
10
+ YieldingStream,
11
+ YieldingStreamOptions,
12
+ ToolCallsEvent,
13
+ } from './types/yielding-stream.js';
14
+ import type { ToolResult } from './types/tools.js';
15
+
16
+ // ============================================================================
17
+ // Internal State Types
18
+ // ============================================================================
19
+
20
+ type StreamState =
21
+ | { status: 'idle' }
22
+ | { status: 'streaming' }
23
+ | { status: 'waiting_for_tools'; pendingCallIds: string[] }
24
+ | { status: 'done' }
25
+ | { status: 'error'; error: Error };
26
+
27
+ interface PendingToolResults {
28
+ resolve: (results: ToolResult[]) => void;
29
+ reject: (error: Error) => void;
30
+ }
31
+
32
+ // ============================================================================
33
+ // YieldingStreamImpl
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Implementation of the YieldingStream interface.
38
+ *
39
+ * This class manages:
40
+ * - An event queue for yielding events to the consumer
41
+ * - A promise-based handshake for tool results
42
+ * - Cancellation via AbortController
43
+ * - State tracking for debugging and validation
44
+ */
45
+ export class YieldingStreamImpl implements YieldingStream {
46
+ private state: StreamState = { status: 'idle' };
47
+ private eventQueue: StreamEvent[] = [];
48
+ private pendingToolResults: PendingToolResults | null = null;
49
+ private abortController: AbortController;
50
+ private _toolDepth = 0;
51
+
52
+ // Promise/resolver for the async iterator to wait on new events
53
+ private eventWaiter: {
54
+ resolve: () => void;
55
+ promise: Promise<void>;
56
+ } | null = null;
57
+
58
+ // Flag indicating the stream producer is done
59
+ private producerDone = false;
60
+
61
+ constructor(
62
+ private readonly options: YieldingStreamOptions,
63
+ private readonly runInference: (stream: YieldingStreamImpl) => Promise<void>
64
+ ) {
65
+ this.abortController = new AbortController();
66
+
67
+ // Link external signal if provided
68
+ if (options.signal) {
69
+ options.signal.addEventListener('abort', () => {
70
+ this.cancel();
71
+ });
72
+ }
73
+ }
74
+
75
+ // ============================================================================
76
+ // Public Interface
77
+ // ============================================================================
78
+
79
+ get isWaitingForTools(): boolean {
80
+ return this.state.status === 'waiting_for_tools';
81
+ }
82
+
83
+ get pendingToolCallIds(): string[] {
84
+ if (this.state.status === 'waiting_for_tools') {
85
+ return this.state.pendingCallIds;
86
+ }
87
+ return [];
88
+ }
89
+
90
+ get toolDepth(): number {
91
+ return this._toolDepth;
92
+ }
93
+
94
+ /**
95
+ * Get the abort signal for use in internal operations.
96
+ */
97
+ get signal(): AbortSignal {
98
+ return this.abortController.signal;
99
+ }
100
+
101
+ provideToolResults(results: ToolResult[]): void {
102
+ if (this.state.status !== 'waiting_for_tools') {
103
+ throw new Error(
104
+ `Cannot provide tool results: stream is not waiting for tools (status: ${this.state.status})`
105
+ );
106
+ }
107
+
108
+ if (!this.pendingToolResults) {
109
+ throw new Error('Internal error: no pending tool results promise');
110
+ }
111
+
112
+ // Validate that results match pending call IDs
113
+ const pendingIds = new Set(this.state.pendingCallIds);
114
+ const providedIds = new Set(results.map((r) => r.toolUseId));
115
+
116
+ for (const id of pendingIds) {
117
+ if (!providedIds.has(id)) {
118
+ throw new Error(`Missing tool result for call ID: ${id}`);
119
+ }
120
+ }
121
+
122
+ // Resolve the promise and transition state
123
+ this.pendingToolResults.resolve(results);
124
+ this.pendingToolResults = null;
125
+ this.state = { status: 'streaming' };
126
+ this._toolDepth++;
127
+ }
128
+
129
+ cancel(): void {
130
+ if (this.state.status === 'done' || this.state.status === 'error') {
131
+ return; // Already terminated
132
+ }
133
+
134
+ this.abortController.abort();
135
+
136
+ // If waiting for tools, reject the pending promise
137
+ if (this.pendingToolResults) {
138
+ this.pendingToolResults.reject(new Error('Stream cancelled'));
139
+ this.pendingToolResults = null;
140
+ }
141
+
142
+ // Emit aborted event and wake the iterator so it can deliver it
143
+ this.emit({ type: 'aborted', reason: 'user' });
144
+ this.producerDone = true;
145
+ this.state = { status: 'done' };
146
+ }
147
+
148
+ // ============================================================================
149
+ // Async Iterator Implementation
150
+ // ============================================================================
151
+
152
+ [Symbol.asyncIterator](): AsyncIterator<StreamEvent> {
153
+ // Start the inference loop when iteration begins
154
+ this.startInference();
155
+
156
+ return {
157
+ next: async (): Promise<IteratorResult<StreamEvent>> => {
158
+ while (true) {
159
+ // Check for queued events
160
+ const event = this.eventQueue.shift();
161
+ if (event) {
162
+ // Check if this is a terminal event
163
+ if (
164
+ event.type === 'complete' ||
165
+ event.type === 'error' ||
166
+ event.type === 'aborted'
167
+ ) {
168
+ this.state = { status: 'done' };
169
+ }
170
+ return { value: event, done: false };
171
+ }
172
+
173
+ // If producer is done and queue is empty, we're done
174
+ if (this.producerDone) {
175
+ return { value: undefined as unknown as StreamEvent, done: true };
176
+ }
177
+
178
+ // Wait for more events
179
+ await this.waitForEvent();
180
+ }
181
+ },
182
+ };
183
+ }
184
+
185
+ // ============================================================================
186
+ // Internal Methods (called by the inference loop)
187
+ // ============================================================================
188
+
189
+ /**
190
+ * Push an event to be yielded to the consumer.
191
+ */
192
+ emit(event: StreamEvent): void {
193
+ this.eventQueue.push(event);
194
+ this.notifyEventWaiter();
195
+ }
196
+
197
+ /**
198
+ * Request tool execution and wait for results.
199
+ * Called by the inference loop when tool calls are detected.
200
+ */
201
+ async requestToolExecution(event: ToolCallsEvent): Promise<ToolResult[]> {
202
+ // Emit the tool calls event
203
+ this.emit(event);
204
+
205
+ // Transition to waiting state
206
+ this.state = {
207
+ status: 'waiting_for_tools',
208
+ pendingCallIds: event.calls.map((c) => c.id),
209
+ };
210
+
211
+ // Create a promise that will be resolved by provideToolResults()
212
+ return new Promise<ToolResult[]>((resolve, reject) => {
213
+ this.pendingToolResults = { resolve, reject };
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Mark the producer as done (inference loop finished).
219
+ */
220
+ markDone(): void {
221
+ this.producerDone = true;
222
+ this.notifyEventWaiter();
223
+ }
224
+
225
+ /**
226
+ * Check if the stream has been cancelled.
227
+ */
228
+ get isCancelled(): boolean {
229
+ return this.abortController.signal.aborted;
230
+ }
231
+
232
+ // ============================================================================
233
+ // Private Helpers
234
+ // ============================================================================
235
+
236
+ private startInference(): void {
237
+ if (this.state.status !== 'idle') {
238
+ return; // Already started
239
+ }
240
+
241
+ this.state = { status: 'streaming' };
242
+
243
+ // Run the inference loop in the background
244
+ this.runInference(this)
245
+ .then(() => {
246
+ this.markDone();
247
+ })
248
+ .catch((error) => {
249
+ this.emit({ type: 'error', error });
250
+ this.markDone();
251
+ });
252
+ }
253
+
254
+ private waitForEvent(): Promise<void> {
255
+ if (!this.eventWaiter) {
256
+ let resolve: () => void;
257
+ const promise = new Promise<void>((r) => {
258
+ resolve = r;
259
+ });
260
+ this.eventWaiter = { resolve: resolve!, promise };
261
+ }
262
+ return this.eventWaiter.promise;
263
+ }
264
+
265
+ private notifyEventWaiter(): void {
266
+ if (this.eventWaiter) {
267
+ this.eventWaiter.resolve();
268
+ this.eventWaiter = null;
269
+ }
270
+ }
271
+ }