@aspectly/core 0.1.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,403 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BridgeInternal } from './BridgeInternal';
3
+ import { BridgeEventType, BridgeResultType, BridgeErrorType } from './types';
4
+
5
+ describe('BridgeInternal', () => {
6
+ let sendEvent: ReturnType<typeof vi.fn>;
7
+ let bridge: BridgeInternal;
8
+
9
+ beforeEach(() => {
10
+ sendEvent = vi.fn();
11
+ bridge = new BridgeInternal(sendEvent);
12
+ });
13
+
14
+ describe('init', () => {
15
+ it('should send Init event with methods', () => {
16
+ const handlers = {
17
+ greet: async () => ({ message: 'hello' }),
18
+ getData: async () => ({ data: 'value' }),
19
+ };
20
+
21
+ bridge.init(handlers);
22
+
23
+ expect(sendEvent).toHaveBeenCalledWith({
24
+ type: BridgeEventType.Init,
25
+ data: {
26
+ methods: ['greet', 'getData'],
27
+ },
28
+ });
29
+ });
30
+
31
+ it('should resolve immediately if called with empty handlers', async () => {
32
+ const result = await bridge.init({});
33
+ expect(result).toBe(true);
34
+ expect(sendEvent).not.toHaveBeenCalled();
35
+ });
36
+
37
+ it('should return promise that resolves on InitResult', async () => {
38
+ const handlers = {
39
+ greet: async () => ({ message: 'hello' }),
40
+ };
41
+
42
+ const initPromise = bridge.init(handlers);
43
+
44
+ // Simulate receiving InitResult
45
+ bridge.handleCoreEvent({
46
+ type: BridgeEventType.InitResult,
47
+ data: true,
48
+ });
49
+
50
+ await expect(initPromise).resolves.toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('handleCoreEvent', () => {
55
+ it('should handle Init event and send InitResult', () => {
56
+ bridge.handleCoreEvent({
57
+ type: BridgeEventType.Init,
58
+ data: { methods: ['test'] },
59
+ });
60
+
61
+ expect(sendEvent).toHaveBeenCalledWith({
62
+ type: BridgeEventType.InitResult,
63
+ data: true,
64
+ });
65
+ expect(bridge.isAvailable()).toBe(true);
66
+ expect(bridge.supports('test')).toBe(true);
67
+ });
68
+
69
+ it('should handle Request event and call handler', async () => {
70
+ const handler = vi.fn().mockResolvedValue({ success: true });
71
+
72
+ // First initialize the bridge by receiving Init from other side
73
+ bridge.handleCoreEvent({
74
+ type: BridgeEventType.Init,
75
+ data: { methods: [] },
76
+ });
77
+
78
+ // Start init and simulate InitResult response
79
+ const initPromise = bridge.init({ greet: handler });
80
+ bridge.handleCoreEvent({
81
+ type: BridgeEventType.InitResult,
82
+ data: true,
83
+ });
84
+ await initPromise;
85
+ sendEvent.mockClear();
86
+
87
+ bridge.handleCoreEvent({
88
+ type: BridgeEventType.Request,
89
+ data: {
90
+ method: 'greet',
91
+ params: { name: 'John' },
92
+ request_id: '1',
93
+ },
94
+ });
95
+
96
+ // Wait for async handler
97
+ await new Promise((resolve) => setTimeout(resolve, 50));
98
+
99
+ expect(handler).toHaveBeenCalledWith({ name: 'John' });
100
+ });
101
+
102
+ it('should send success result after handler completes', async () => {
103
+ const handler = vi.fn().mockResolvedValue({ message: 'Hello!' });
104
+
105
+ // Initialize bridge
106
+ bridge.handleCoreEvent({
107
+ type: BridgeEventType.Init,
108
+ data: { methods: [] },
109
+ });
110
+
111
+ const initPromise = bridge.init({ greet: handler });
112
+ bridge.handleCoreEvent({
113
+ type: BridgeEventType.InitResult,
114
+ data: true,
115
+ });
116
+ await initPromise;
117
+ sendEvent.mockClear();
118
+
119
+ bridge.handleCoreEvent({
120
+ type: BridgeEventType.Request,
121
+ data: {
122
+ method: 'greet',
123
+ params: {},
124
+ request_id: '1',
125
+ },
126
+ });
127
+
128
+ await new Promise((resolve) => setTimeout(resolve, 50));
129
+
130
+ expect(sendEvent).toHaveBeenCalledWith({
131
+ type: BridgeEventType.Result,
132
+ data: {
133
+ type: BridgeResultType.Success,
134
+ data: { message: 'Hello!' },
135
+ method: 'greet',
136
+ request_id: '1',
137
+ },
138
+ });
139
+ });
140
+
141
+ it('should send error result for unsupported methods', async () => {
142
+ bridge.handleCoreEvent({
143
+ type: BridgeEventType.Request,
144
+ data: {
145
+ method: 'unknownMethod',
146
+ params: {},
147
+ request_id: '1',
148
+ },
149
+ });
150
+
151
+ await new Promise((resolve) => setTimeout(resolve, 50));
152
+
153
+ expect(sendEvent).toHaveBeenCalledWith({
154
+ type: BridgeEventType.Result,
155
+ data: {
156
+ type: BridgeResultType.Error,
157
+ request_id: '1',
158
+ method: 'unknownMethod',
159
+ data: {
160
+ error_message: expect.any(String),
161
+ error_type: BridgeErrorType.UNSUPPORTED_METHOD,
162
+ },
163
+ },
164
+ });
165
+ });
166
+
167
+ it('should send error result when handler throws', async () => {
168
+ const handler = vi.fn().mockRejectedValue(new Error('Handler error'));
169
+
170
+ // Initialize bridge
171
+ bridge.handleCoreEvent({
172
+ type: BridgeEventType.Init,
173
+ data: { methods: [] },
174
+ });
175
+
176
+ const initPromise = bridge.init({ failing: handler });
177
+ bridge.handleCoreEvent({
178
+ type: BridgeEventType.InitResult,
179
+ data: true,
180
+ });
181
+ await initPromise;
182
+ sendEvent.mockClear();
183
+
184
+ bridge.handleCoreEvent({
185
+ type: BridgeEventType.Request,
186
+ data: {
187
+ method: 'failing',
188
+ params: {},
189
+ request_id: '1',
190
+ },
191
+ });
192
+
193
+ await new Promise((resolve) => setTimeout(resolve, 50));
194
+
195
+ expect(sendEvent).toHaveBeenCalledWith({
196
+ type: BridgeEventType.Result,
197
+ data: {
198
+ type: BridgeResultType.Error,
199
+ request_id: '1',
200
+ method: 'failing',
201
+ data: {
202
+ error_message: 'Handler error',
203
+ error_type: BridgeErrorType.REJECTED,
204
+ },
205
+ },
206
+ });
207
+ });
208
+ });
209
+
210
+ describe('send', () => {
211
+ beforeEach(() => {
212
+ // Initialize bridge
213
+ bridge.handleCoreEvent({
214
+ type: BridgeEventType.Init,
215
+ data: { methods: ['test'] },
216
+ });
217
+ });
218
+
219
+ it('should send request event', () => {
220
+ bridge.send('test', { foo: 'bar' });
221
+
222
+ expect(sendEvent).toHaveBeenCalledWith({
223
+ type: BridgeEventType.Request,
224
+ data: {
225
+ method: 'test',
226
+ params: { foo: 'bar' },
227
+ request_id: expect.any(String),
228
+ },
229
+ });
230
+ });
231
+
232
+ it('should resolve promise on success result', async () => {
233
+ const promise = bridge.send<{ message: string }>('test', {});
234
+
235
+ // Simulate result
236
+ bridge.handleCoreEvent({
237
+ type: BridgeEventType.Result,
238
+ data: {
239
+ type: BridgeResultType.Success,
240
+ data: { message: 'success' },
241
+ request_id: '0',
242
+ method: 'test',
243
+ },
244
+ });
245
+
246
+ await expect(promise).resolves.toEqual({ message: 'success' });
247
+ });
248
+
249
+ it('should reject promise on error result', async () => {
250
+ const promise = bridge.send('test', {});
251
+
252
+ bridge.handleCoreEvent({
253
+ type: BridgeEventType.Result,
254
+ data: {
255
+ type: BridgeResultType.Error,
256
+ data: {
257
+ error_type: BridgeErrorType.REJECTED,
258
+ error_message: 'Failed',
259
+ },
260
+ request_id: '0',
261
+ method: 'test',
262
+ },
263
+ });
264
+
265
+ await expect(promise).rejects.toEqual({
266
+ error_type: BridgeErrorType.REJECTED,
267
+ error_message: 'Failed',
268
+ });
269
+ });
270
+
271
+ it('should reject with BRIDGE_NOT_AVAILABLE when not initialized', async () => {
272
+ const uninitializedBridge = new BridgeInternal(sendEvent);
273
+ const promise = uninitializedBridge.send('test', {});
274
+
275
+ await expect(promise).rejects.toEqual({
276
+ error_type: BridgeErrorType.BRIDGE_NOT_AVAILABLE,
277
+ error_message: 'Bridge is not available',
278
+ });
279
+ });
280
+ });
281
+
282
+ describe('subscribe/unsubscribe', () => {
283
+ it('should notify listeners on result events', () => {
284
+ const listener = vi.fn();
285
+ bridge.subscribe(listener);
286
+
287
+ bridge.handleCoreEvent({
288
+ type: BridgeEventType.Result,
289
+ data: {
290
+ type: BridgeResultType.Success,
291
+ data: { foo: 'bar' },
292
+ method: 'test',
293
+ },
294
+ });
295
+
296
+ expect(listener).toHaveBeenCalledWith({
297
+ type: BridgeResultType.Success,
298
+ data: { foo: 'bar' },
299
+ method: 'test',
300
+ });
301
+ });
302
+
303
+ it('should stop notifying after unsubscribe', () => {
304
+ const listener = vi.fn();
305
+ bridge.subscribe(listener);
306
+ bridge.unsubscribe(listener);
307
+
308
+ bridge.handleCoreEvent({
309
+ type: BridgeEventType.Result,
310
+ data: {
311
+ type: BridgeResultType.Success,
312
+ data: {},
313
+ method: 'test',
314
+ },
315
+ });
316
+
317
+ expect(listener).not.toHaveBeenCalled();
318
+ });
319
+ });
320
+
321
+ describe('supports', () => {
322
+ it('should return false before initialization', () => {
323
+ expect(bridge.supports('test')).toBe(false);
324
+ });
325
+
326
+ it('should return true for supported methods after init', () => {
327
+ bridge.handleCoreEvent({
328
+ type: BridgeEventType.Init,
329
+ data: { methods: ['greet', 'getData'] },
330
+ });
331
+
332
+ expect(bridge.supports('greet')).toBe(true);
333
+ expect(bridge.supports('getData')).toBe(true);
334
+ expect(bridge.supports('unknown')).toBe(false);
335
+ });
336
+ });
337
+
338
+ describe('isAvailable', () => {
339
+ it('should return false before initialization', () => {
340
+ expect(bridge.isAvailable()).toBe(false);
341
+ });
342
+
343
+ it('should return true after Init event', () => {
344
+ bridge.handleCoreEvent({
345
+ type: BridgeEventType.Init,
346
+ data: { methods: [] },
347
+ });
348
+
349
+ expect(bridge.isAvailable()).toBe(true);
350
+ });
351
+ });
352
+
353
+ describe('timeout', () => {
354
+ it('should use custom timeout from options', async () => {
355
+ vi.useFakeTimers();
356
+
357
+ const customBridge = new BridgeInternal(sendEvent, { timeout: 100 });
358
+
359
+ // Initialize bridge first
360
+ customBridge.handleCoreEvent({
361
+ type: BridgeEventType.Init,
362
+ data: { methods: [] },
363
+ });
364
+
365
+ const slowHandler = vi.fn().mockImplementation(
366
+ () => new Promise((resolve) => setTimeout(resolve, 200))
367
+ );
368
+ const initPromise = customBridge.init({ slow: slowHandler });
369
+ customBridge.handleCoreEvent({
370
+ type: BridgeEventType.InitResult,
371
+ data: true,
372
+ });
373
+ await initPromise;
374
+ sendEvent.mockClear();
375
+
376
+ customBridge.handleCoreEvent({
377
+ type: BridgeEventType.Request,
378
+ data: {
379
+ method: 'slow',
380
+ params: {},
381
+ request_id: '1',
382
+ },
383
+ });
384
+
385
+ await vi.advanceTimersByTimeAsync(150);
386
+
387
+ expect(sendEvent).toHaveBeenCalledWith({
388
+ type: BridgeEventType.Result,
389
+ data: {
390
+ type: BridgeResultType.Error,
391
+ request_id: '1',
392
+ method: 'slow',
393
+ data: {
394
+ error_message: 'Execution timeout exceeded',
395
+ error_type: BridgeErrorType.METHOD_EXECUTION_TIMEOUT,
396
+ },
397
+ },
398
+ });
399
+
400
+ vi.useRealTimers();
401
+ });
402
+ });
403
+ });
@@ -0,0 +1,305 @@
1
+ import type {
2
+ BridgeData,
3
+ BridgeEvent,
4
+ BridgeHandlers,
5
+ BridgeInitEvent,
6
+ BridgeInitResultEvent,
7
+ BridgeListener,
8
+ BridgeOptions,
9
+ BridgeRequestEvent,
10
+ BridgeResultData,
11
+ BridgeResultError,
12
+ BridgeResultEvent,
13
+ } from './types';
14
+ import {
15
+ BridgeErrorType,
16
+ BridgeEventType,
17
+ BridgeResultType,
18
+ } from './types';
19
+
20
+ /**
21
+ * Creates a bridge event with the specified type and data
22
+ */
23
+ export const internalEvent = (
24
+ type: BridgeEventType,
25
+ data: BridgeData
26
+ ): BridgeEvent => ({
27
+ type,
28
+ data,
29
+ });
30
+
31
+ /**
32
+ * Creates a result event
33
+ */
34
+ export const internalResultEvent = (data: BridgeData): BridgeEvent =>
35
+ internalEvent(BridgeEventType.Result, data);
36
+
37
+ interface InternalRequestPromise {
38
+ reject: (error: BridgeResultError) => void;
39
+ resolve: (result: BridgeResultData) => void;
40
+ }
41
+
42
+ interface InitPromise {
43
+ reject: () => void;
44
+ resolve: (success: boolean) => void;
45
+ }
46
+
47
+ type InternalEventSender = (event: BridgeEvent) => void;
48
+
49
+ const DEFAULT_TIMEOUT = 100000;
50
+
51
+ /**
52
+ * BridgeInternal handles the business logic of the bridge protocol.
53
+ * It manages request/response lifecycle, handler registration, and event routing.
54
+ */
55
+ export class BridgeInternal {
56
+ private requests: InternalRequestPromise[] = [];
57
+ private handlers: BridgeHandlers = {};
58
+ private available = false;
59
+ private supportedMethods: string[] = [];
60
+ private listeners: BridgeListener[] = [];
61
+ private initPromise?: InitPromise;
62
+ private readonly sendEvent: InternalEventSender;
63
+ private readonly timeout: number;
64
+
65
+ constructor(sendEvent: InternalEventSender, options?: BridgeOptions) {
66
+ this.sendEvent = sendEvent;
67
+ this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
68
+ }
69
+
70
+ /**
71
+ * Subscribe to all result events
72
+ */
73
+ public subscribe = (listener: BridgeListener): number => {
74
+ return this.listeners.push(listener);
75
+ };
76
+
77
+ /**
78
+ * Unsubscribe from result events
79
+ */
80
+ public unsubscribe = (listener: BridgeListener): void => {
81
+ this.listeners = this.listeners.filter(
82
+ (oldListener) => oldListener !== listener
83
+ );
84
+ };
85
+
86
+ private checkDiff = (a: string[], b: string[]): boolean => {
87
+ return (
88
+ a.filter((x) => !b.includes(x)).length > 0 ||
89
+ b.filter((x) => !a.includes(x)).length > 0
90
+ );
91
+ };
92
+
93
+ /**
94
+ * Initialize the bridge with handlers
95
+ * @param handlers Map of method names to handler functions
96
+ * @returns Promise that resolves when the other side acknowledges
97
+ */
98
+ public init = (handlers: BridgeHandlers = {}): Promise<boolean> => {
99
+ const oldMethods = Object.keys(this.handlers);
100
+ const newMethods = Object.keys(handlers);
101
+ this.handlers = handlers;
102
+ if (!this.checkDiff(oldMethods, newMethods)) {
103
+ return Promise.resolve(true);
104
+ }
105
+ return new Promise((resolve, reject) => {
106
+ this.initPromise = { resolve, reject };
107
+ this.sendEvent(
108
+ internalEvent(BridgeEventType.Init, {
109
+ methods: newMethods,
110
+ })
111
+ );
112
+ });
113
+ };
114
+
115
+ /**
116
+ * Handle incoming bridge events
117
+ */
118
+ public handleCoreEvent = (event: BridgeEvent): void => {
119
+ const { type, data } = event;
120
+ switch (type) {
121
+ case BridgeEventType.Init:
122
+ this.handleInit(data as BridgeInitEvent);
123
+ break;
124
+ case BridgeEventType.InitResult:
125
+ this.handleInitResult(data as BridgeInitResultEvent);
126
+ break;
127
+ case BridgeEventType.Request:
128
+ this.handleRequest(data as BridgeRequestEvent);
129
+ break;
130
+ case BridgeEventType.Result:
131
+ this.handleResult(data as BridgeResultEvent);
132
+ break;
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Handle incoming requests and execute the appropriate handler
138
+ */
139
+ public handleRequest = (request: BridgeRequestEvent): void => {
140
+ const { method, params, request_id } = request;
141
+ new Promise<BridgeResultData>((resolve, reject) => {
142
+ let timeout = false;
143
+ if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) {
144
+ reject({
145
+ error_type: BridgeErrorType.UNSUPPORTED_METHOD,
146
+ error: new Error(`Handler for «${method}» is not registered`),
147
+ });
148
+ return;
149
+ }
150
+ const timer = setTimeout(() => {
151
+ timeout = true;
152
+ reject({
153
+ error_type: BridgeErrorType.METHOD_EXECUTION_TIMEOUT,
154
+ error: new Error('Execution timeout exceeded'),
155
+ });
156
+ }, this.timeout);
157
+ const handler = this.handlers[method];
158
+ if (!handler) {
159
+ reject({
160
+ error_type: BridgeErrorType.UNSUPPORTED_METHOD,
161
+ error: new Error(`Handler for «${method}» is undefined`),
162
+ });
163
+ return;
164
+ }
165
+ handler(params)
166
+ .then((result) => {
167
+ if (timeout) {
168
+ return;
169
+ }
170
+ clearTimeout(timer);
171
+ resolve(result as BridgeResultData);
172
+ })
173
+ .catch((error: Error) => {
174
+ if (timeout) {
175
+ return;
176
+ }
177
+ clearTimeout(timer);
178
+ reject({
179
+ error_type: BridgeErrorType.REJECTED,
180
+ error: error,
181
+ });
182
+ });
183
+ })
184
+ .then((data: BridgeResultData) => {
185
+ this.sendEvent(
186
+ internalResultEvent({
187
+ type: BridgeResultType.Success,
188
+ data,
189
+ method,
190
+ request_id,
191
+ })
192
+ );
193
+ })
194
+ .catch(
195
+ ({
196
+ error_type,
197
+ error,
198
+ }: {
199
+ error_type: BridgeErrorType;
200
+ error: Error;
201
+ }) => {
202
+ this.sendEvent(
203
+ internalResultEvent({
204
+ type: BridgeResultType.Error,
205
+ request_id,
206
+ method,
207
+ data: {
208
+ error_message: error.message,
209
+ error_type,
210
+ },
211
+ })
212
+ );
213
+ }
214
+ );
215
+ };
216
+
217
+ private handleResult = (result: BridgeResultEvent): void => {
218
+ this.handleRequestResult(result);
219
+ this.listeners.forEach((listener) => listener(result));
220
+ };
221
+
222
+ private handleRequestResult = (result: BridgeResultEvent): void => {
223
+ if (!result || !Object.prototype.hasOwnProperty.call(result, 'request_id')) {
224
+ return;
225
+ }
226
+ if (!Object.prototype.hasOwnProperty.call(result, 'type')) {
227
+ console.warn('unknown result', result);
228
+ return;
229
+ }
230
+ const { request_id, data, type } = result;
231
+ const request = this.requests[Number(request_id)];
232
+ if (!request) {
233
+ return;
234
+ }
235
+ if (type === BridgeResultType.Success) {
236
+ request.resolve(data);
237
+ return;
238
+ }
239
+ if (type === BridgeResultType.Error) {
240
+ request.reject(data as BridgeResultError);
241
+ }
242
+ };
243
+
244
+ private handleInit = (data: BridgeInitEvent): void => {
245
+ this.available = true;
246
+ this.supportedMethods = data.methods;
247
+ this.sendEvent(internalEvent(BridgeEventType.InitResult, true));
248
+ };
249
+
250
+ private handleInitResult = (success: BridgeInitResultEvent): void => {
251
+ if (success) {
252
+ this.initPromise?.resolve(true);
253
+ } else {
254
+ this.initPromise?.reject();
255
+ }
256
+ };
257
+
258
+ /**
259
+ * Send a request to the other side
260
+ * @param method Method name to invoke
261
+ * @param params Parameters to pass to the method
262
+ * @returns Promise that resolves with the result
263
+ */
264
+ public send = <TResult = unknown>(
265
+ method: string,
266
+ params: object
267
+ ): Promise<TResult> =>
268
+ new Promise((resolve, reject) => {
269
+ const request_id = (
270
+ this.requests.push({ resolve: resolve as (result: BridgeResultData) => void, reject }) - 1
271
+ ).toString();
272
+ if (!this.isAvailable()) {
273
+ this.handleCoreEvent(
274
+ internalResultEvent({
275
+ type: BridgeResultType.Error,
276
+ request_id,
277
+ method,
278
+ data: {
279
+ error_message: 'Bridge is not available',
280
+ error_type: BridgeErrorType.BRIDGE_NOT_AVAILABLE,
281
+ },
282
+ })
283
+ );
284
+ return;
285
+ }
286
+ this.sendEvent(
287
+ internalEvent(BridgeEventType.Request, {
288
+ method,
289
+ params,
290
+ request_id,
291
+ })
292
+ );
293
+ });
294
+
295
+ /**
296
+ * Check if a method is supported by the other side
297
+ */
298
+ public supports = (method: string): boolean =>
299
+ this.supportedMethods.includes(method);
300
+
301
+ /**
302
+ * Check if the bridge is available (initialized)
303
+ */
304
+ public isAvailable = (): boolean => this.available;
305
+ }