@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,132 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BridgeBase } from './BridgeBase';
3
+ import { BridgeInternal } from './BridgeInternal';
4
+
5
+ describe('BridgeBase', () => {
6
+ let mockBridge: BridgeInternal;
7
+ let bridgeBase: BridgeBase;
8
+
9
+ beforeEach(() => {
10
+ mockBridge = {
11
+ supports: vi.fn().mockReturnValue(true),
12
+ isAvailable: vi.fn().mockReturnValue(true),
13
+ send: vi.fn().mockResolvedValue({ result: 'value' }),
14
+ subscribe: vi.fn().mockReturnValue(1),
15
+ unsubscribe: vi.fn(),
16
+ init: vi.fn().mockResolvedValue(true),
17
+ } as unknown as BridgeInternal;
18
+
19
+ bridgeBase = new BridgeBase(mockBridge);
20
+ });
21
+
22
+ describe('supports', () => {
23
+ it('should delegate to internal bridge', () => {
24
+ bridgeBase.supports('testMethod');
25
+
26
+ expect(mockBridge.supports).toHaveBeenCalledWith('testMethod');
27
+ });
28
+
29
+ it('should return the result from internal bridge', () => {
30
+ (mockBridge.supports as ReturnType<typeof vi.fn>).mockReturnValue(false);
31
+
32
+ expect(bridgeBase.supports('unknown')).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe('isAvailable', () => {
37
+ it('should delegate to internal bridge', () => {
38
+ bridgeBase.isAvailable();
39
+
40
+ expect(mockBridge.isAvailable).toHaveBeenCalled();
41
+ });
42
+
43
+ it('should return the result from internal bridge', () => {
44
+ (mockBridge.isAvailable as ReturnType<typeof vi.fn>).mockReturnValue(false);
45
+
46
+ expect(bridgeBase.isAvailable()).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('send', () => {
51
+ it('should delegate to internal bridge with method and params', async () => {
52
+ await bridgeBase.send('testMethod', { foo: 'bar' });
53
+
54
+ expect(mockBridge.send).toHaveBeenCalledWith('testMethod', { foo: 'bar' });
55
+ });
56
+
57
+ it('should use empty object as default params', async () => {
58
+ await bridgeBase.send('testMethod');
59
+
60
+ expect(mockBridge.send).toHaveBeenCalledWith('testMethod', {});
61
+ });
62
+
63
+ it('should return the result from internal bridge', async () => {
64
+ (mockBridge.send as ReturnType<typeof vi.fn>).mockResolvedValue({
65
+ message: 'Hello',
66
+ });
67
+
68
+ const result = await bridgeBase.send<{ message: string }>('greet');
69
+
70
+ expect(result).toEqual({ message: 'Hello' });
71
+ });
72
+
73
+ it('should propagate errors from internal bridge', async () => {
74
+ const error = { error_type: 'REJECTED', error_message: 'Failed' };
75
+ (mockBridge.send as ReturnType<typeof vi.fn>).mockRejectedValue(error);
76
+
77
+ await expect(bridgeBase.send('failing')).rejects.toEqual(error);
78
+ });
79
+ });
80
+
81
+ describe('subscribe', () => {
82
+ it('should delegate to internal bridge', () => {
83
+ const listener = vi.fn();
84
+ bridgeBase.subscribe(listener);
85
+
86
+ expect(mockBridge.subscribe).toHaveBeenCalledWith(listener);
87
+ });
88
+
89
+ it('should return subscription index', () => {
90
+ (mockBridge.subscribe as ReturnType<typeof vi.fn>).mockReturnValue(5);
91
+
92
+ const result = bridgeBase.subscribe(vi.fn());
93
+
94
+ expect(result).toBe(5);
95
+ });
96
+ });
97
+
98
+ describe('unsubscribe', () => {
99
+ it('should delegate to internal bridge', () => {
100
+ const listener = vi.fn();
101
+ bridgeBase.unsubscribe(listener);
102
+
103
+ expect(mockBridge.unsubscribe).toHaveBeenCalledWith(listener);
104
+ });
105
+ });
106
+
107
+ describe('init', () => {
108
+ it('should delegate to internal bridge with handlers', async () => {
109
+ const handlers = {
110
+ greet: async () => ({ message: 'hello' }),
111
+ };
112
+
113
+ await bridgeBase.init(handlers);
114
+
115
+ expect(mockBridge.init).toHaveBeenCalledWith(handlers);
116
+ });
117
+
118
+ it('should work without handlers', async () => {
119
+ await bridgeBase.init();
120
+
121
+ expect(mockBridge.init).toHaveBeenCalledWith(undefined);
122
+ });
123
+
124
+ it('should return the result from internal bridge', async () => {
125
+ (mockBridge.init as ReturnType<typeof vi.fn>).mockResolvedValue(false);
126
+
127
+ const result = await bridgeBase.init();
128
+
129
+ expect(result).toBe(false);
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,63 @@
1
+ import type { BridgeHandlers, BridgeListener } from './types';
2
+ import type { BridgeInternal } from './BridgeInternal';
3
+
4
+ /**
5
+ * BridgeBase provides the public API for bridge communication.
6
+ * It wraps BridgeInternal and exposes a clean interface for consumers.
7
+ */
8
+ export class BridgeBase {
9
+ protected bridge: BridgeInternal;
10
+
11
+ constructor(bridge: BridgeInternal) {
12
+ this.bridge = bridge;
13
+ }
14
+
15
+ /**
16
+ * Check if a method is supported by the other side
17
+ * @param method Method name to check
18
+ */
19
+ public supports = (method: string): boolean => this.bridge.supports(method);
20
+
21
+ /**
22
+ * Check if the bridge is available (initialized)
23
+ */
24
+ public isAvailable = (): boolean => this.bridge.isAvailable();
25
+
26
+ /**
27
+ * Send a request to invoke a method on the other side
28
+ * @param method Method name to invoke
29
+ * @param params Parameters to pass
30
+ * @returns Promise resolving with the result
31
+ */
32
+ public send = <TResult = unknown>(
33
+ method: string,
34
+ params: object = {}
35
+ ): Promise<TResult> => {
36
+ return this.bridge.send<TResult>(method, params);
37
+ };
38
+
39
+ /**
40
+ * Subscribe to all result events
41
+ * @param listener Callback for result events
42
+ * @returns Subscription index
43
+ */
44
+ public subscribe = (listener: BridgeListener): number => {
45
+ return this.bridge.subscribe(listener);
46
+ };
47
+
48
+ /**
49
+ * Unsubscribe from result events
50
+ * @param listener The listener to remove
51
+ */
52
+ public unsubscribe = (listener: BridgeListener): void => {
53
+ return this.bridge.unsubscribe(listener);
54
+ };
55
+
56
+ /**
57
+ * Initialize the bridge with handlers
58
+ * @param handlers Map of method names to handler functions
59
+ * @returns Promise resolving when initialization is complete
60
+ */
61
+ public init = (handlers?: BridgeHandlers): Promise<boolean> =>
62
+ this.bridge.init(handlers);
63
+ }
@@ -0,0 +1,254 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { BridgeCore } from './BridgeCore';
3
+
4
+ describe('BridgeCore', () => {
5
+ beforeEach(() => {
6
+ vi.clearAllMocks();
7
+ });
8
+
9
+ describe('wrapBridgeEvent', () => {
10
+ it('should wrap an event in the bridge protocol format', () => {
11
+ const event = { method: 'test', params: { foo: 'bar' } };
12
+ const wrapped = BridgeCore.wrapBridgeEvent(event);
13
+
14
+ expect(typeof wrapped).toBe('string');
15
+ const parsed = JSON.parse(wrapped);
16
+ expect(parsed.type).toBe('BridgeEvent');
17
+ expect(parsed.event).toEqual(event);
18
+ });
19
+
20
+ it('should handle complex nested objects', () => {
21
+ const event = {
22
+ method: 'test',
23
+ params: {
24
+ nested: { deep: { value: 123 } },
25
+ array: [1, 2, 3],
26
+ },
27
+ };
28
+ const wrapped = BridgeCore.wrapBridgeEvent(event);
29
+ const parsed = JSON.parse(wrapped);
30
+ expect(parsed.event).toEqual(event);
31
+ });
32
+ });
33
+
34
+ describe('wrapListener', () => {
35
+ it('should parse valid bridge events', () => {
36
+ const listener = vi.fn();
37
+ const wrappedListener = BridgeCore.wrapListener(listener);
38
+ const event = { method: 'test' };
39
+ const data = JSON.stringify({ type: 'BridgeEvent', event });
40
+
41
+ wrappedListener(data);
42
+
43
+ expect(listener).toHaveBeenCalledWith(event);
44
+ });
45
+
46
+ it('should ignore non-string data', () => {
47
+ const listener = vi.fn();
48
+ const wrappedListener = BridgeCore.wrapListener(listener);
49
+
50
+ wrappedListener(undefined);
51
+ wrappedListener(123 as unknown as string);
52
+
53
+ expect(listener).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it('should ignore empty strings', () => {
57
+ const listener = vi.fn();
58
+ const wrappedListener = BridgeCore.wrapListener(listener);
59
+
60
+ wrappedListener('');
61
+
62
+ expect(listener).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('should handle iOS wrapped JSON (with single quotes)', () => {
66
+ const listener = vi.fn();
67
+ const wrappedListener = BridgeCore.wrapListener(listener);
68
+ const event = { method: 'test' };
69
+ const data = `'${JSON.stringify({ type: 'BridgeEvent', event })}'`;
70
+
71
+ wrappedListener(data);
72
+
73
+ expect(listener).toHaveBeenCalledWith(event);
74
+ });
75
+
76
+ it('should ignore non-JSON data', () => {
77
+ const listener = vi.fn();
78
+ const wrappedListener = BridgeCore.wrapListener(listener);
79
+
80
+ wrappedListener('not json');
81
+ wrappedListener('[1, 2, 3]');
82
+
83
+ expect(listener).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('should ignore events with wrong type', () => {
87
+ const listener = vi.fn();
88
+ const wrappedListener = BridgeCore.wrapListener(listener);
89
+ const data = JSON.stringify({ type: 'WrongType', event: {} });
90
+
91
+ wrappedListener(data);
92
+
93
+ expect(listener).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('should handle invalid JSON gracefully', () => {
97
+ const listener = vi.fn();
98
+ const wrappedListener = BridgeCore.wrapListener(listener);
99
+
100
+ wrappedListener('{invalid json}');
101
+
102
+ expect(listener).not.toHaveBeenCalled();
103
+ });
104
+ });
105
+
106
+ describe('browserListener', () => {
107
+ it('should extract data from MessageEvent', () => {
108
+ const listener = vi.fn();
109
+ const browserListener = BridgeCore.browserListener(listener);
110
+ const event = { method: 'test' };
111
+ const messageEvent = {
112
+ data: JSON.stringify({ type: 'BridgeEvent', event }),
113
+ } as MessageEvent;
114
+
115
+ browserListener(messageEvent);
116
+
117
+ expect(listener).toHaveBeenCalledWith(event);
118
+ });
119
+
120
+ it('should ignore events without data', () => {
121
+ const listener = vi.fn();
122
+ const browserListener = BridgeCore.browserListener(listener);
123
+
124
+ browserListener({ data: undefined } as MessageEvent);
125
+ browserListener({} as MessageEvent);
126
+
127
+ expect(listener).not.toHaveBeenCalled();
128
+ });
129
+ });
130
+
131
+ describe('webViewListener', () => {
132
+ it('should extract data from WebView nativeEvent', () => {
133
+ const listener = vi.fn();
134
+ const webViewListener = BridgeCore.webViewListener(listener);
135
+ const event = { method: 'test' };
136
+ const webViewMessage = {
137
+ nativeEvent: {
138
+ data: JSON.stringify({ type: 'BridgeEvent', event }),
139
+ },
140
+ };
141
+
142
+ webViewListener(webViewMessage);
143
+
144
+ expect(listener).toHaveBeenCalledWith(event);
145
+ });
146
+
147
+ it('should ignore messages without nativeEvent', () => {
148
+ const listener = vi.fn();
149
+ const webViewListener = BridgeCore.webViewListener(listener);
150
+
151
+ webViewListener({});
152
+ webViewListener({ nativeEvent: {} });
153
+ webViewListener({ nativeEvent: { data: undefined } });
154
+
155
+ expect(listener).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+
159
+ describe('sendEvent', () => {
160
+ let originalParent: Window;
161
+ let originalRNW: typeof window.ReactNativeWebView;
162
+
163
+ beforeEach(() => {
164
+ originalParent = window.parent;
165
+ originalRNW = window.ReactNativeWebView;
166
+ });
167
+
168
+ afterEach(() => {
169
+ Object.defineProperty(window, 'parent', {
170
+ value: originalParent,
171
+ writable: true,
172
+ configurable: true,
173
+ });
174
+ (window as any).ReactNativeWebView = originalRNW;
175
+ });
176
+
177
+ it('should send to iframe parent when available', () => {
178
+ (window as any).ReactNativeWebView = undefined;
179
+ const mockParent = {
180
+ postMessage: vi.fn(),
181
+ };
182
+ Object.defineProperty(window, 'parent', {
183
+ value: mockParent,
184
+ writable: true,
185
+ configurable: true,
186
+ });
187
+
188
+ const event = { method: 'test' };
189
+ BridgeCore.sendEvent(event);
190
+
191
+ expect(mockParent.postMessage).toHaveBeenCalledWith(
192
+ expect.any(String),
193
+ '*'
194
+ );
195
+ });
196
+
197
+ it('should send to ReactNativeWebView when available', () => {
198
+ const mockRNW = {
199
+ postMessage: vi.fn(),
200
+ };
201
+ (window as any).ReactNativeWebView = mockRNW;
202
+
203
+ const event = { method: 'test' };
204
+ BridgeCore.sendEvent(event);
205
+
206
+ expect(mockRNW.postMessage).toHaveBeenCalledWith(expect.any(String));
207
+ });
208
+
209
+ it('should not send when window.parent === window (top level)', () => {
210
+ (window as any).ReactNativeWebView = undefined;
211
+ Object.defineProperty(window, 'parent', {
212
+ value: window,
213
+ writable: true,
214
+ configurable: true,
215
+ });
216
+
217
+ const event = { method: 'test' };
218
+ // This should not throw
219
+ expect(() => BridgeCore.sendEvent(event)).not.toThrow();
220
+ });
221
+ });
222
+
223
+ describe('subscribe', () => {
224
+ it('should subscribe to message events', () => {
225
+ const listener = vi.fn();
226
+ const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
227
+
228
+ const unsubscribe = BridgeCore.subscribe(listener);
229
+
230
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
231
+ 'message',
232
+ expect.any(Function)
233
+ );
234
+ expect(typeof unsubscribe).toBe('function');
235
+
236
+ addEventListenerSpy.mockRestore();
237
+ });
238
+
239
+ it('should return cleanup function that removes listener', () => {
240
+ const listener = vi.fn();
241
+ const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
242
+
243
+ const unsubscribe = BridgeCore.subscribe(listener);
244
+ unsubscribe();
245
+
246
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
247
+ 'message',
248
+ expect.any(Function)
249
+ );
250
+
251
+ removeEventListenerSpy.mockRestore();
252
+ });
253
+ });
254
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Low-level bridge core handling platform-specific message passing
3
+ */
4
+
5
+ export type Event = unknown;
6
+
7
+ interface BridgeCoreEvent {
8
+ type: string;
9
+ event: Event;
10
+ }
11
+
12
+ interface WebViewMessage {
13
+ nativeEvent?: {
14
+ data?: string;
15
+ };
16
+ }
17
+
18
+ export type BridgeCoreListener = (event: Event) => void;
19
+
20
+ declare global {
21
+ interface Window {
22
+ ReactNativeWebView?: {
23
+ postMessage: (message: string) => void;
24
+ };
25
+ }
26
+ }
27
+
28
+ /**
29
+ * BridgeCore handles the low-level platform detection and message serialization.
30
+ * It provides static methods for wrapping events, creating listeners, and sending messages.
31
+ */
32
+ export class BridgeCore {
33
+ private static BRIDGE_EVENT_TYPE = 'BridgeEvent';
34
+
35
+ private static isJSONObject = (str: string): boolean => {
36
+ return str.startsWith('{') && str.endsWith('}');
37
+ };
38
+
39
+ /**
40
+ * Wraps an event in the bridge protocol format
41
+ */
42
+ public static wrapBridgeEvent = (event: Event): string => {
43
+ return JSON.stringify({
44
+ event,
45
+ type: BridgeCore.BRIDGE_EVENT_TYPE,
46
+ });
47
+ };
48
+
49
+ /**
50
+ * Creates a listener wrapper that parses incoming messages
51
+ */
52
+ static wrapListener =
53
+ (listener: BridgeCoreListener) =>
54
+ (data?: string): void => {
55
+ if (typeof data !== 'string') {
56
+ return;
57
+ }
58
+ if (!data) {
59
+ return;
60
+ }
61
+ let processedData = data;
62
+ // iOS wraps JSON with additional quotes
63
+ if (processedData.startsWith("'") && processedData.endsWith("'")) {
64
+ processedData = processedData.substring(1, processedData.length - 1);
65
+ }
66
+ if (!BridgeCore.isJSONObject(processedData)) {
67
+ return;
68
+ }
69
+ try {
70
+ const eventData: BridgeCoreEvent = JSON.parse(processedData);
71
+ if (!eventData || eventData.type !== BridgeCore.BRIDGE_EVENT_TYPE) {
72
+ return;
73
+ }
74
+ listener(eventData.event);
75
+ } catch {
76
+ // Ignore parse errors
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Creates a browser-specific message event listener
82
+ */
83
+ static browserListener = (listener: BridgeCoreListener) => {
84
+ const triggerEvent = BridgeCore.wrapListener(listener);
85
+ return (originalEvent: MessageEvent): void => {
86
+ if (!originalEvent?.data) {
87
+ return;
88
+ }
89
+ triggerEvent(originalEvent.data);
90
+ };
91
+ };
92
+
93
+ /**
94
+ * Creates a React Native WebView message listener
95
+ */
96
+ static webViewListener = (listener: BridgeCoreListener) => {
97
+ const triggerEvent = BridgeCore.wrapListener(listener);
98
+ return (originalEvent: WebViewMessage): void => {
99
+ if (!originalEvent?.nativeEvent?.data) {
100
+ return;
101
+ }
102
+ triggerEvent(originalEvent.nativeEvent.data);
103
+ };
104
+ };
105
+
106
+ /**
107
+ * Sends an event to the parent context (WebView or iframe parent)
108
+ */
109
+ static sendEvent = (event: Event): void => {
110
+ const bridgeEvent = BridgeCore.wrapBridgeEvent(event);
111
+ if (typeof window === 'undefined') {
112
+ console.warn('Window is undefined');
113
+ return;
114
+ }
115
+ const RNW = window.ReactNativeWebView;
116
+ if (typeof RNW?.postMessage === 'function') {
117
+ RNW.postMessage(`'${bridgeEvent}'`);
118
+ return;
119
+ }
120
+ if (window.parent === window) {
121
+ return;
122
+ }
123
+ window.parent.postMessage(bridgeEvent, '*');
124
+ };
125
+
126
+ /**
127
+ * Subscribes to window message events
128
+ * @returns Cleanup function to unsubscribe
129
+ */
130
+ static subscribe = (listener: BridgeCoreListener): VoidFunction => {
131
+ const browserListener = BridgeCore.browserListener(listener);
132
+ if (typeof window === 'undefined' || !window.addEventListener) {
133
+ return () => {};
134
+ }
135
+ window.addEventListener('message', browserListener);
136
+ return () => window.removeEventListener('message', browserListener);
137
+ };
138
+ }