@aspectly/transports 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.
- package/LICENSE +20 -0
- package/README.md +368 -0
- package/dist/BaseTransport-CxzIr1Ds.d.mts +80 -0
- package/dist/BaseTransport-CxzIr1Ds.d.ts +80 -0
- package/dist/cefsharp.d.mts +37 -0
- package/dist/cefsharp.d.ts +37 -0
- package/dist/cefsharp.js +65 -0
- package/dist/cefsharp.js.map +1 -0
- package/dist/cefsharp.mjs +62 -0
- package/dist/cefsharp.mjs.map +1 -0
- package/dist/iframe.d.mts +35 -0
- package/dist/iframe.d.ts +35 -0
- package/dist/iframe.js +75 -0
- package/dist/iframe.js.map +1 -0
- package/dist/iframe.mjs +72 -0
- package/dist/iframe.mjs.map +1 -0
- package/dist/index.d.mts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +374 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +358 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react-native.d.mts +36 -0
- package/dist/react-native.d.ts +36 -0
- package/dist/react-native.js +65 -0
- package/dist/react-native.js.map +1 -0
- package/dist/react-native.mjs +62 -0
- package/dist/react-native.mjs.map +1 -0
- package/dist/window.d.mts +36 -0
- package/dist/window.d.ts +36 -0
- package/dist/window.js +79 -0
- package/dist/window.js.map +1 -0
- package/dist/window.mjs +76 -0
- package/dist/window.mjs.map +1 -0
- package/package.json +97 -0
- package/src/BaseTransport.test.ts +60 -0
- package/src/BaseTransport.ts +27 -0
- package/src/TransportRegistry.test.ts +345 -0
- package/src/TransportRegistry.ts +120 -0
- package/src/cefsharp.ts +3 -0
- package/src/iframe.ts +3 -0
- package/src/index.ts +26 -0
- package/src/react-native.ts +3 -0
- package/src/transports/CefSharpTransport.test.ts +187 -0
- package/src/transports/CefSharpTransport.ts +73 -0
- package/src/transports/IframeTransport.test.ts +212 -0
- package/src/transports/IframeTransport.ts +79 -0
- package/src/transports/NullTransport.test.ts +64 -0
- package/src/transports/NullTransport.ts +27 -0
- package/src/transports/PostMessageTransport.ts +50 -0
- package/src/transports/ReactNativeTransport.test.ts +196 -0
- package/src/transports/ReactNativeTransport.ts +73 -0
- package/src/transports/WindowTransport.ts +84 -0
- package/src/types.ts +69 -0
- package/src/window.ts +3 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { BaseTransport } from '../BaseTransport';
|
|
2
|
+
import type { TransportListener, TransportUnsubscribe, TransportDetector } from '../types';
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
CefSharp?: {
|
|
7
|
+
PostMessage: (message: string) => void;
|
|
8
|
+
BindObjectAsync: (...args: unknown[]) => Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Transport for CefSharp (Chromium Embedded Framework for .NET)
|
|
15
|
+
* Used in desktop applications with embedded Chromium browsers
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { CefSharpTransport } from '@aspectly/transports/cefsharp';
|
|
20
|
+
*
|
|
21
|
+
* const transport = new CefSharpTransport();
|
|
22
|
+
* if (transport.isAvailable()) {
|
|
23
|
+
* transport.send(JSON.stringify({ type: 'hello' }));
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export class CefSharpTransport extends BaseTransport {
|
|
28
|
+
readonly name = 'cefsharp';
|
|
29
|
+
|
|
30
|
+
isAvailable(): boolean {
|
|
31
|
+
const win = this.getWindow();
|
|
32
|
+
return typeof win?.CefSharp?.PostMessage === 'function';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
send(message: string): void {
|
|
36
|
+
const win = this.getWindow();
|
|
37
|
+
if (!win?.CefSharp?.PostMessage) {
|
|
38
|
+
console.warn('[CefSharpTransport] CefSharp.PostMessage is not available');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
win.CefSharp.PostMessage(message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
subscribe(listener: TransportListener): TransportUnsubscribe {
|
|
45
|
+
const win = this.getWindow();
|
|
46
|
+
if (!win) {
|
|
47
|
+
return () => {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// CefSharp sends messages via window.postMessage
|
|
51
|
+
const handler = (event: MessageEvent): void => {
|
|
52
|
+
if (typeof event.data === 'string') {
|
|
53
|
+
listener(event.data);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
win.addEventListener('message', handler);
|
|
58
|
+
return () => win.removeEventListener('message', handler);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detector for auto-detection registry
|
|
64
|
+
*/
|
|
65
|
+
export const cefSharpDetector: TransportDetector = {
|
|
66
|
+
name: 'cefsharp',
|
|
67
|
+
priority: 100, // Highest priority - check first
|
|
68
|
+
detect: () => {
|
|
69
|
+
return typeof window !== 'undefined' &&
|
|
70
|
+
typeof window.CefSharp?.PostMessage === 'function';
|
|
71
|
+
},
|
|
72
|
+
createTransport: () => new CefSharpTransport(),
|
|
73
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { IframeTransport, iframeDetector } from './IframeTransport';
|
|
3
|
+
|
|
4
|
+
describe('IframeTransport', () => {
|
|
5
|
+
let transport: IframeTransport;
|
|
6
|
+
let originalParent: Window;
|
|
7
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
transport = new IframeTransport();
|
|
11
|
+
originalParent = window.parent;
|
|
12
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
Object.defineProperty(window, 'parent', {
|
|
17
|
+
value: originalParent,
|
|
18
|
+
writable: true,
|
|
19
|
+
configurable: true,
|
|
20
|
+
});
|
|
21
|
+
consoleWarnSpy.mockRestore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should have name property equal to "iframe"', () => {
|
|
25
|
+
expect(transport.name).toBe('iframe');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should have default targetOrigin of "*"', () => {
|
|
29
|
+
// Access private property for testing
|
|
30
|
+
expect((transport as any).targetOrigin).toBe('*');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should accept custom targetOrigin in constructor', () => {
|
|
34
|
+
const customTransport = new IframeTransport('https://example.com');
|
|
35
|
+
expect((customTransport as any).targetOrigin).toBe('https://example.com');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should return false when not in iframe (parent === window)', () => {
|
|
39
|
+
Object.defineProperty(window, 'parent', {
|
|
40
|
+
value: window,
|
|
41
|
+
writable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
expect(transport.isAvailable()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return true when in iframe (parent !== window)', () => {
|
|
48
|
+
const mockParent = { ...window, isParent: true };
|
|
49
|
+
Object.defineProperty(window, 'parent', {
|
|
50
|
+
value: mockParent,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
});
|
|
54
|
+
expect(transport.isAvailable()).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call window.parent.postMessage with message and targetOrigin', () => {
|
|
58
|
+
const mockPostMessage = vi.fn();
|
|
59
|
+
const mockParent = { postMessage: mockPostMessage };
|
|
60
|
+
Object.defineProperty(window, 'parent', {
|
|
61
|
+
value: mockParent,
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
transport.send('test message');
|
|
67
|
+
|
|
68
|
+
expect(mockPostMessage).toHaveBeenCalledWith('test message', '*');
|
|
69
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should use custom targetOrigin when sending', () => {
|
|
73
|
+
const customTransport = new IframeTransport('https://example.com');
|
|
74
|
+
const mockPostMessage = vi.fn();
|
|
75
|
+
const mockParent = { postMessage: mockPostMessage };
|
|
76
|
+
Object.defineProperty(window, 'parent', {
|
|
77
|
+
value: mockParent,
|
|
78
|
+
writable: true,
|
|
79
|
+
configurable: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
customTransport.send('test message');
|
|
83
|
+
|
|
84
|
+
expect(mockPostMessage).toHaveBeenCalledWith('test message', 'https://example.com');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should warn when not in iframe during send', () => {
|
|
88
|
+
Object.defineProperty(window, 'parent', {
|
|
89
|
+
value: window,
|
|
90
|
+
writable: true,
|
|
91
|
+
configurable: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
transport.send('test message');
|
|
95
|
+
|
|
96
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith('[IframeTransport] Not inside an iframe');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should add message event listener when subscribing', () => {
|
|
100
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
|
101
|
+
const listener = vi.fn();
|
|
102
|
+
|
|
103
|
+
transport.subscribe(listener);
|
|
104
|
+
|
|
105
|
+
expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
106
|
+
|
|
107
|
+
addEventListenerSpy.mockRestore();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should call listener with string data from message event', () => {
|
|
111
|
+
const listener = vi.fn();
|
|
112
|
+
transport.subscribe(listener);
|
|
113
|
+
|
|
114
|
+
const event = new MessageEvent('message', { data: 'test data' });
|
|
115
|
+
window.dispatchEvent(event);
|
|
116
|
+
|
|
117
|
+
expect(listener).toHaveBeenCalledWith('test data');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should not call listener with non-string data', () => {
|
|
121
|
+
const listener = vi.fn();
|
|
122
|
+
transport.subscribe(listener);
|
|
123
|
+
|
|
124
|
+
const event1 = new MessageEvent('message', { data: 123 });
|
|
125
|
+
const event2 = new MessageEvent('message', { data: { foo: 'bar' } });
|
|
126
|
+
const event3 = new MessageEvent('message', { data: null });
|
|
127
|
+
|
|
128
|
+
window.dispatchEvent(event1);
|
|
129
|
+
window.dispatchEvent(event2);
|
|
130
|
+
window.dispatchEvent(event3);
|
|
131
|
+
|
|
132
|
+
expect(listener).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should remove event listener when cleanup function is called', () => {
|
|
136
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
|
137
|
+
const listener = vi.fn();
|
|
138
|
+
|
|
139
|
+
const unsubscribe = transport.subscribe(listener);
|
|
140
|
+
unsubscribe();
|
|
141
|
+
|
|
142
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
143
|
+
|
|
144
|
+
removeEventListenerSpy.mockRestore();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should not receive messages after unsubscribing', () => {
|
|
148
|
+
const listener = vi.fn();
|
|
149
|
+
const unsubscribe = transport.subscribe(listener);
|
|
150
|
+
|
|
151
|
+
const event1 = new MessageEvent('message', { data: 'before unsubscribe' });
|
|
152
|
+
window.dispatchEvent(event1);
|
|
153
|
+
|
|
154
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
155
|
+
|
|
156
|
+
unsubscribe();
|
|
157
|
+
|
|
158
|
+
const event2 = new MessageEvent('message', { data: 'after unsubscribe' });
|
|
159
|
+
window.dispatchEvent(event2);
|
|
160
|
+
|
|
161
|
+
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('iframeDetector', () => {
|
|
166
|
+
let originalParent: Window;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
originalParent = window.parent;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
Object.defineProperty(window, 'parent', {
|
|
174
|
+
value: originalParent,
|
|
175
|
+
writable: true,
|
|
176
|
+
configurable: true,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should have name property equal to "iframe"', () => {
|
|
181
|
+
expect(iframeDetector.name).toBe('iframe');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should have priority of 80', () => {
|
|
185
|
+
expect(iframeDetector.priority).toBe(80);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should detect() return false when not in iframe', () => {
|
|
189
|
+
Object.defineProperty(window, 'parent', {
|
|
190
|
+
value: window,
|
|
191
|
+
writable: true,
|
|
192
|
+
configurable: true,
|
|
193
|
+
});
|
|
194
|
+
expect(iframeDetector.detect()).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should detect() return true when in iframe', () => {
|
|
198
|
+
const mockParent = { ...window, isParent: true };
|
|
199
|
+
Object.defineProperty(window, 'parent', {
|
|
200
|
+
value: mockParent,
|
|
201
|
+
writable: true,
|
|
202
|
+
configurable: true,
|
|
203
|
+
});
|
|
204
|
+
expect(iframeDetector.detect()).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should createTransport() return IframeTransport instance', () => {
|
|
208
|
+
const transport = iframeDetector.createTransport();
|
|
209
|
+
expect(transport).toBeInstanceOf(IframeTransport);
|
|
210
|
+
expect(transport.name).toBe('iframe');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { BaseTransport } from '../BaseTransport';
|
|
2
|
+
import type { TransportListener, TransportUnsubscribe, TransportDetector } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Transport for iframe/window.postMessage communication
|
|
6
|
+
* Used when web content is displayed inside an iframe
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { IframeTransport } from '@aspectly/transports/iframe';
|
|
11
|
+
*
|
|
12
|
+
* const transport = new IframeTransport();
|
|
13
|
+
* if (transport.isAvailable()) {
|
|
14
|
+
* transport.send(JSON.stringify({ type: 'hello' }));
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export class IframeTransport extends BaseTransport {
|
|
19
|
+
readonly name = 'iframe';
|
|
20
|
+
private readonly targetOrigin: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an iframe transport
|
|
24
|
+
* @param targetOrigin Origin to send messages to (default: '*')
|
|
25
|
+
*/
|
|
26
|
+
constructor(targetOrigin: string = '*') {
|
|
27
|
+
super();
|
|
28
|
+
this.targetOrigin = targetOrigin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
isAvailable(): boolean {
|
|
32
|
+
const win = this.getWindow();
|
|
33
|
+
if (!win) return false;
|
|
34
|
+
// Check if we're inside an iframe (parent !== self)
|
|
35
|
+
return win.parent !== win;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
send(message: string): void {
|
|
39
|
+
const win = this.getWindow();
|
|
40
|
+
if (!win) {
|
|
41
|
+
console.warn('[IframeTransport] Window is not available');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (win.parent === win) {
|
|
45
|
+
console.warn('[IframeTransport] Not inside an iframe');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
win.parent.postMessage(message, this.targetOrigin);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribe(listener: TransportListener): TransportUnsubscribe {
|
|
52
|
+
const win = this.getWindow();
|
|
53
|
+
if (!win) {
|
|
54
|
+
return () => {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handler = (event: MessageEvent): void => {
|
|
58
|
+
// Optionally filter by origin here if targetOrigin !== '*'
|
|
59
|
+
if (typeof event.data === 'string') {
|
|
60
|
+
listener(event.data);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
win.addEventListener('message', handler);
|
|
65
|
+
return () => win.removeEventListener('message', handler);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detector for auto-detection registry
|
|
71
|
+
*/
|
|
72
|
+
export const iframeDetector: TransportDetector = {
|
|
73
|
+
name: 'iframe',
|
|
74
|
+
priority: 80, // Lowest priority - fallback
|
|
75
|
+
detect: () => {
|
|
76
|
+
return typeof window !== 'undefined' && window.parent !== window;
|
|
77
|
+
},
|
|
78
|
+
createTransport: () => new IframeTransport(),
|
|
79
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { NullTransport } from './NullTransport';
|
|
3
|
+
|
|
4
|
+
describe('NullTransport', () => {
|
|
5
|
+
let transport: NullTransport;
|
|
6
|
+
let originalNodeEnv: string | undefined;
|
|
7
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
transport = new NullTransport();
|
|
11
|
+
originalNodeEnv = process.env.NODE_ENV;
|
|
12
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
17
|
+
consoleWarnSpy.mockRestore();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should have name property equal to "null"', () => {
|
|
21
|
+
expect(transport.name).toBe('null');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should have isAvailable() always return true', () => {
|
|
25
|
+
expect(transport.isAvailable()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should have send() be a no-op without errors', () => {
|
|
29
|
+
expect(() => transport.send('test message')).not.toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should log warning in development mode when send() is called', () => {
|
|
33
|
+
process.env.NODE_ENV = 'development';
|
|
34
|
+
transport.send('test message');
|
|
35
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
36
|
+
'[NullTransport] No transport available, message not sent'
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should not log warning in production mode when send() is called', () => {
|
|
41
|
+
process.env.NODE_ENV = 'production';
|
|
42
|
+
transport.send('test message');
|
|
43
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should have subscribe() return a cleanup function', () => {
|
|
47
|
+
const listener = vi.fn();
|
|
48
|
+
const unsubscribe = transport.subscribe(listener);
|
|
49
|
+
expect(typeof unsubscribe).toBe('function');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should have subscribe() cleanup function be a no-op without errors', () => {
|
|
53
|
+
const listener = vi.fn();
|
|
54
|
+
const unsubscribe = transport.subscribe(listener);
|
|
55
|
+
expect(() => unsubscribe()).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should never call listener (messages are no-op)', () => {
|
|
59
|
+
const listener = vi.fn();
|
|
60
|
+
transport.subscribe(listener);
|
|
61
|
+
// Since NullTransport doesn't actually receive messages, listener should never be called
|
|
62
|
+
expect(listener).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BaseTransport } from '../BaseTransport';
|
|
2
|
+
import type { TransportListener, TransportUnsubscribe } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Null transport - used when no transport is available
|
|
6
|
+
* All operations are no-ops. Useful for SSR or testing.
|
|
7
|
+
*/
|
|
8
|
+
export class NullTransport extends BaseTransport {
|
|
9
|
+
readonly name = 'null';
|
|
10
|
+
|
|
11
|
+
isAvailable(): boolean {
|
|
12
|
+
// Always "available" as a fallback, but does nothing
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
send(_message: string): void {
|
|
17
|
+
// No-op
|
|
18
|
+
if (process.env.NODE_ENV === 'development') {
|
|
19
|
+
console.warn('[NullTransport] No transport available, message not sent');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
subscribe(_listener: TransportListener): TransportUnsubscribe {
|
|
24
|
+
// No-op
|
|
25
|
+
return () => {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BaseTransport } from '../BaseTransport';
|
|
2
|
+
import type { TransportListener, TransportUnsubscribe, TransportDetector } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fallback transport for browser environments.
|
|
6
|
+
* Listens for window.postMessage events, allowing parent windows
|
|
7
|
+
* to receive messages from child iframes.
|
|
8
|
+
*/
|
|
9
|
+
export class PostMessageTransport extends BaseTransport {
|
|
10
|
+
readonly name = 'postmessage';
|
|
11
|
+
|
|
12
|
+
isAvailable(): boolean {
|
|
13
|
+
return typeof this.getWindow()?.addEventListener === 'function';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
send(message: string): void {
|
|
17
|
+
const win = this.getWindow();
|
|
18
|
+
if (!win) return;
|
|
19
|
+
win.postMessage(message, '*');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
subscribe(listener: TransportListener): TransportUnsubscribe {
|
|
23
|
+
const win = this.getWindow();
|
|
24
|
+
if (!win) {
|
|
25
|
+
return () => {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handler = (event: MessageEvent): void => {
|
|
29
|
+
if (typeof event.data === 'string') {
|
|
30
|
+
listener(event.data);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
win.addEventListener('message', handler);
|
|
35
|
+
return () => win.removeEventListener('message', handler);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detector for auto-detection registry
|
|
41
|
+
*/
|
|
42
|
+
export const postMessageDetector: TransportDetector = {
|
|
43
|
+
name: 'postmessage',
|
|
44
|
+
priority: 10, // Lowest priority — fallback for any browser environment
|
|
45
|
+
detect: () => {
|
|
46
|
+
return typeof window !== 'undefined' &&
|
|
47
|
+
typeof window.addEventListener === 'function';
|
|
48
|
+
},
|
|
49
|
+
createTransport: () => new PostMessageTransport(),
|
|
50
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ReactNativeTransport, reactNativeDetector } from './ReactNativeTransport';
|
|
3
|
+
|
|
4
|
+
interface ReactNativeWindow extends Window {
|
|
5
|
+
ReactNativeWebView?: {
|
|
6
|
+
postMessage: (message: string) => void;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const testWindow = window as ReactNativeWindow;
|
|
11
|
+
|
|
12
|
+
describe('ReactNativeTransport', () => {
|
|
13
|
+
let transport: ReactNativeTransport;
|
|
14
|
+
let originalRNW: typeof testWindow.ReactNativeWebView;
|
|
15
|
+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
transport = new ReactNativeTransport();
|
|
19
|
+
originalRNW = testWindow.ReactNativeWebView;
|
|
20
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
testWindow.ReactNativeWebView = originalRNW;
|
|
25
|
+
consoleWarnSpy.mockRestore();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should have name property equal to "react-native"', () => {
|
|
29
|
+
expect(transport.name).toBe('react-native');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should return false when ReactNativeWebView not present', () => {
|
|
33
|
+
testWindow.ReactNativeWebView = undefined;
|
|
34
|
+
expect(transport.isAvailable()).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should return false when ReactNativeWebView.postMessage not a function', () => {
|
|
38
|
+
testWindow.ReactNativeWebView = {} as any;
|
|
39
|
+
expect(transport.isAvailable()).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return true when ReactNativeWebView.postMessage exists', () => {
|
|
43
|
+
testWindow.ReactNativeWebView = {
|
|
44
|
+
postMessage: vi.fn(),
|
|
45
|
+
};
|
|
46
|
+
expect(transport.isAvailable()).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should call ReactNativeWebView.postMessage with quoted message (iOS quirk)', () => {
|
|
50
|
+
const mockPostMessage = vi.fn();
|
|
51
|
+
testWindow.ReactNativeWebView = {
|
|
52
|
+
postMessage: mockPostMessage,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
transport.send('test message');
|
|
56
|
+
|
|
57
|
+
// iOS quirk: message is wrapped in quotes
|
|
58
|
+
expect(mockPostMessage).toHaveBeenCalledWith("'test message'");
|
|
59
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle JSON messages with proper quoting', () => {
|
|
63
|
+
const mockPostMessage = vi.fn();
|
|
64
|
+
testWindow.ReactNativeWebView = {
|
|
65
|
+
postMessage: mockPostMessage,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const jsonMessage = JSON.stringify({ type: 'test', data: 'foo' });
|
|
69
|
+
transport.send(jsonMessage);
|
|
70
|
+
|
|
71
|
+
expect(mockPostMessage).toHaveBeenCalledWith(`'${jsonMessage}'`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should warn when ReactNativeWebView not available during send', () => {
|
|
75
|
+
testWindow.ReactNativeWebView = undefined;
|
|
76
|
+
|
|
77
|
+
transport.send('test message');
|
|
78
|
+
|
|
79
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
80
|
+
'[ReactNativeTransport] ReactNativeWebView.postMessage is not available'
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should warn when ReactNativeWebView.postMessage not available during send', () => {
|
|
85
|
+
testWindow.ReactNativeWebView = {} as any;
|
|
86
|
+
|
|
87
|
+
transport.send('test message');
|
|
88
|
+
|
|
89
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
90
|
+
'[ReactNativeTransport] ReactNativeWebView.postMessage is not available'
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should add message event listener when subscribing', () => {
|
|
95
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
|
96
|
+
const listener = vi.fn();
|
|
97
|
+
|
|
98
|
+
transport.subscribe(listener);
|
|
99
|
+
|
|
100
|
+
expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
101
|
+
|
|
102
|
+
addEventListenerSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should call listener with string data from message event', () => {
|
|
106
|
+
const listener = vi.fn();
|
|
107
|
+
transport.subscribe(listener);
|
|
108
|
+
|
|
109
|
+
const event = new MessageEvent('message', { data: 'test data' });
|
|
110
|
+
window.dispatchEvent(event);
|
|
111
|
+
|
|
112
|
+
expect(listener).toHaveBeenCalledWith('test data');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should not call listener with non-string data', () => {
|
|
116
|
+
const listener = vi.fn();
|
|
117
|
+
transport.subscribe(listener);
|
|
118
|
+
|
|
119
|
+
const event1 = new MessageEvent('message', { data: 123 });
|
|
120
|
+
const event2 = new MessageEvent('message', { data: { foo: 'bar' } });
|
|
121
|
+
const event3 = new MessageEvent('message', { data: null });
|
|
122
|
+
|
|
123
|
+
window.dispatchEvent(event1);
|
|
124
|
+
window.dispatchEvent(event2);
|
|
125
|
+
window.dispatchEvent(event3);
|
|
126
|
+
|
|
127
|
+
expect(listener).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should remove event listener when cleanup function is called', () => {
|
|
131
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
|
132
|
+
const listener = vi.fn();
|
|
133
|
+
|
|
134
|
+
const unsubscribe = transport.subscribe(listener);
|
|
135
|
+
unsubscribe();
|
|
136
|
+
|
|
137
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
138
|
+
|
|
139
|
+
removeEventListenerSpy.mockRestore();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should not receive messages after unsubscribing', () => {
|
|
143
|
+
const listener = vi.fn();
|
|
144
|
+
const unsubscribe = transport.subscribe(listener);
|
|
145
|
+
|
|
146
|
+
const event1 = new MessageEvent('message', { data: 'before unsubscribe' });
|
|
147
|
+
window.dispatchEvent(event1);
|
|
148
|
+
|
|
149
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
150
|
+
|
|
151
|
+
unsubscribe();
|
|
152
|
+
|
|
153
|
+
const event2 = new MessageEvent('message', { data: 'after unsubscribe' });
|
|
154
|
+
window.dispatchEvent(event2);
|
|
155
|
+
|
|
156
|
+
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('reactNativeDetector', () => {
|
|
161
|
+
let originalRNW: typeof testWindow.ReactNativeWebView;
|
|
162
|
+
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
originalRNW = testWindow.ReactNativeWebView;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterEach(() => {
|
|
168
|
+
testWindow.ReactNativeWebView = originalRNW;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should have name property equal to "react-native"', () => {
|
|
172
|
+
expect(reactNativeDetector.name).toBe('react-native');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should have priority of 90', () => {
|
|
176
|
+
expect(reactNativeDetector.priority).toBe(90);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should detect() return false when ReactNativeWebView not present', () => {
|
|
180
|
+
testWindow.ReactNativeWebView = undefined;
|
|
181
|
+
expect(reactNativeDetector.detect()).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should detect() return true when ReactNativeWebView.postMessage exists', () => {
|
|
185
|
+
testWindow.ReactNativeWebView = {
|
|
186
|
+
postMessage: vi.fn(),
|
|
187
|
+
};
|
|
188
|
+
expect(reactNativeDetector.detect()).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should createTransport() return ReactNativeTransport instance', () => {
|
|
192
|
+
const transport = reactNativeDetector.createTransport();
|
|
193
|
+
expect(transport).toBeInstanceOf(ReactNativeTransport);
|
|
194
|
+
expect(transport.name).toBe('react-native');
|
|
195
|
+
});
|
|
196
|
+
});
|