@adobe/uix-core 0.6.5-1 → 0.7.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/dist/__helpers__/jest.messagechannel.d.cts +2 -0
- package/dist/__helpers__/jest.messagechannel.d.cts.map +1 -0
- package/dist/__mocks__/mock-finalization-registry.d.ts +11 -0
- package/dist/__mocks__/mock-finalization-registry.d.ts.map +1 -0
- package/dist/__mocks__/mock-weak-ref.d.ts +7 -0
- package/dist/__mocks__/mock-weak-ref.d.ts.map +1 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/cross-realm-object.d.ts +44 -0
- package/dist/cross-realm-object.d.ts.map +1 -0
- package/dist/debuglog.d.ts +11 -0
- package/dist/debuglog.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +817 -7
- package/dist/index.js.map +1 -1
- package/dist/message-wrapper.d.ts +9 -0
- package/dist/message-wrapper.d.ts.map +1 -0
- package/dist/object-simulator.d.ts +28 -0
- package/dist/object-simulator.d.ts.map +1 -0
- package/dist/object-simulator.test.d.ts +2 -0
- package/dist/object-simulator.test.d.ts.map +1 -0
- package/dist/object-walker.d.ts +29 -0
- package/dist/object-walker.d.ts.map +1 -0
- package/dist/promises/index.d.ts +3 -0
- package/dist/promises/index.d.ts.map +1 -0
- package/dist/promises/promise-wrappers.test.d.ts +2 -0
- package/dist/promises/promise-wrappers.test.d.ts.map +1 -0
- package/dist/promises/timed.d.ts +15 -0
- package/dist/promises/timed.d.ts.map +1 -0
- package/dist/promises/wait.d.ts +7 -0
- package/dist/promises/wait.d.ts.map +1 -0
- package/dist/remote-subject.d.ts +70 -0
- package/dist/remote-subject.d.ts.map +1 -0
- package/dist/rpc/call-receiver.d.ts +4 -0
- package/dist/rpc/call-receiver.d.ts.map +1 -0
- package/dist/rpc/call-receiver.test.d.ts +2 -0
- package/dist/rpc/call-receiver.test.d.ts.map +1 -0
- package/dist/rpc/call-sender.d.ts +4 -0
- package/dist/rpc/call-sender.d.ts.map +1 -0
- package/dist/rpc/call-sender.test.d.ts +2 -0
- package/dist/rpc/call-sender.test.d.ts.map +1 -0
- package/dist/rpc/index.d.ts +3 -0
- package/dist/rpc/index.d.ts.map +1 -0
- package/dist/tickets.d.ts +34 -0
- package/dist/tickets.d.ts.map +1 -0
- package/dist/tunnel/index.d.ts +2 -0
- package/dist/tunnel/index.d.ts.map +1 -0
- package/dist/tunnel/tunnel-message.d.ts +19 -0
- package/dist/tunnel/tunnel-message.d.ts.map +1 -0
- package/dist/tunnel/tunnel.d.ts +58 -0
- package/dist/tunnel/tunnel.d.ts.map +1 -0
- package/dist/tunnel/tunnel.test.d.ts +2 -0
- package/dist/tunnel/tunnel.test.d.ts.map +1 -0
- package/dist/types.d.ts +1 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/value-assertions.d.ts +10 -0
- package/dist/value-assertions.d.ts.map +1 -0
- package/package.json +4 -1
- package/src/__helpers__/jest.messagechannel.cjs +3 -0
- package/src/__mocks__/mock-finalization-registry.ts +13 -0
- package/src/__mocks__/mock-weak-ref.ts +10 -0
- package/src/constants.ts +6 -0
- package/src/cross-realm-object.ts +117 -0
- package/src/debuglog.ts +1 -1
- package/src/index.ts +4 -1
- package/src/message-wrapper.ts +35 -0
- package/src/object-simulator.test.ts +135 -0
- package/src/object-simulator.ts +136 -0
- package/src/object-walker.ts +92 -0
- package/src/promises/index.ts +2 -0
- package/src/promises/promise-wrappers.test.ts +63 -0
- package/src/promises/timed.ts +41 -0
- package/src/promises/wait.ts +10 -0
- package/src/remote-subject.ts +185 -0
- package/src/rpc/call-receiver.test.ts +90 -0
- package/src/rpc/call-receiver.ts +29 -0
- package/src/rpc/call-sender.test.ts +73 -0
- package/src/rpc/call-sender.ts +72 -0
- package/src/rpc/index.ts +2 -0
- package/src/tickets.ts +71 -0
- package/src/tunnel/index.ts +1 -0
- package/src/tunnel/tunnel-message.ts +75 -0
- package/src/tunnel/tunnel.test.ts +196 -0
- package/src/tunnel/tunnel.ts +311 -0
- package/src/types.ts +3 -5
- package/src/value-assertions.ts +48 -0
- package/tsconfig.json +2 -6
- package/dist/timeout-promise.d.ts +0 -12
- package/dist/timeout-promise.d.ts.map +0 -1
- package/src/timeout-promise.ts +0 -36
package/src/tickets.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { INIT_CALLBACK } from "./constants";
|
|
2
|
+
|
|
3
|
+
export interface HandshakeAcceptedTicket {
|
|
4
|
+
// #region Properties
|
|
5
|
+
|
|
6
|
+
accepts: string;
|
|
7
|
+
version: string;
|
|
8
|
+
|
|
9
|
+
// #endregion Properties
|
|
10
|
+
}
|
|
11
|
+
export interface HandshakeOfferedTicket {
|
|
12
|
+
// #region Properties
|
|
13
|
+
|
|
14
|
+
offers: string;
|
|
15
|
+
version: string;
|
|
16
|
+
|
|
17
|
+
// #endregion Properties
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @internal */
|
|
21
|
+
export interface DefTicket {
|
|
22
|
+
// #region Properties
|
|
23
|
+
|
|
24
|
+
fnId: string;
|
|
25
|
+
|
|
26
|
+
// #endregion Properties
|
|
27
|
+
}
|
|
28
|
+
export interface InitTicket extends DefTicket {
|
|
29
|
+
// #region Properties
|
|
30
|
+
|
|
31
|
+
fnId: typeof INIT_CALLBACK;
|
|
32
|
+
|
|
33
|
+
// #endregion Properties
|
|
34
|
+
}
|
|
35
|
+
export interface CallTicket extends DefTicket {
|
|
36
|
+
// #region Properties
|
|
37
|
+
|
|
38
|
+
callId: number;
|
|
39
|
+
|
|
40
|
+
// #endregion Properties
|
|
41
|
+
}
|
|
42
|
+
export interface CallArgsTicket extends CallTicket {
|
|
43
|
+
// #region Properties
|
|
44
|
+
|
|
45
|
+
args: any[];
|
|
46
|
+
|
|
47
|
+
// #endregion Properties
|
|
48
|
+
}
|
|
49
|
+
export interface ResolveTicket extends CallTicket {
|
|
50
|
+
// #region Properties
|
|
51
|
+
|
|
52
|
+
status: "resolve";
|
|
53
|
+
value: any;
|
|
54
|
+
|
|
55
|
+
// #endregion Properties
|
|
56
|
+
}
|
|
57
|
+
export interface RejectTicket extends CallTicket {
|
|
58
|
+
// #region Properties
|
|
59
|
+
|
|
60
|
+
error: Error;
|
|
61
|
+
status: "reject";
|
|
62
|
+
|
|
63
|
+
// #endregion Properties
|
|
64
|
+
}
|
|
65
|
+
export type RespondTicket = ResolveTicket | RejectTicket;
|
|
66
|
+
|
|
67
|
+
export type CleanupTicket = {};
|
|
68
|
+
|
|
69
|
+
export const INIT_TICKET: InitTicket = {
|
|
70
|
+
fnId: INIT_CALLBACK,
|
|
71
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./tunnel";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NS_ROOT, VERSION } from "../constants";
|
|
2
|
+
import { isPlainObject } from "../value-assertions";
|
|
3
|
+
import { WrappedMessage, isWrapped, wrap, unwrap } from "../message-wrapper";
|
|
4
|
+
import { HandshakeAcceptedTicket, HandshakeOfferedTicket } from "../tickets";
|
|
5
|
+
|
|
6
|
+
type Handshake = HandshakeAcceptedTicket | HandshakeOfferedTicket;
|
|
7
|
+
type HandshakeAccepted = WrappedMessage<HandshakeAcceptedTicket>;
|
|
8
|
+
type HandshakeOffered = WrappedMessage<HandshakeOfferedTicket>;
|
|
9
|
+
type HandshakeMessage = HandshakeAccepted | HandshakeOffered;
|
|
10
|
+
|
|
11
|
+
const VERSION_WARNINGS = new Set();
|
|
12
|
+
|
|
13
|
+
export function resetWarnings() {
|
|
14
|
+
VERSION_WARNINGS.clear();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makeAccepted(id: string): HandshakeAccepted {
|
|
18
|
+
return wrap({
|
|
19
|
+
accepts: id,
|
|
20
|
+
version: VERSION,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function makeOffered(id: string): HandshakeOffered {
|
|
24
|
+
return wrap({
|
|
25
|
+
offers: id,
|
|
26
|
+
version: VERSION,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function isHandshakeAccepting(
|
|
30
|
+
message: unknown,
|
|
31
|
+
id: string
|
|
32
|
+
): message is HandshakeAccepted {
|
|
33
|
+
return (
|
|
34
|
+
isHandshake(message) && unwrap(message as HandshakeAccepted).accepts === id
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
export function isHandshakeOffer(
|
|
38
|
+
message: unknown
|
|
39
|
+
): message is HandshakeOffered {
|
|
40
|
+
return (
|
|
41
|
+
isHandshake(message) &&
|
|
42
|
+
typeof unwrap(message as HandshakeOffered).offers === "string"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
export function isHandshake(message: unknown): message is HandshakeMessage {
|
|
46
|
+
if (!isWrapped(message)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const tunnelData: Handshake = unwrap<Handshake>(message as HandshakeMessage);
|
|
50
|
+
if (
|
|
51
|
+
!isPlainObject(tunnelData) ||
|
|
52
|
+
typeof tunnelData.version !== "string" ||
|
|
53
|
+
!(Reflect.has(tunnelData, "accepts") || Reflect.has(tunnelData, "offers"))
|
|
54
|
+
) {
|
|
55
|
+
console.error(
|
|
56
|
+
`malformed tunnel message, message.${NS_ROOT} must be an object with a "version" string and an either an "accepts" or "offers" property containing an ID string.`
|
|
57
|
+
);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const { version } = tunnelData;
|
|
61
|
+
if (version !== VERSION && !VERSION_WARNINGS.has(version)) {
|
|
62
|
+
VERSION_WARNINGS.add(version);
|
|
63
|
+
console.warn(
|
|
64
|
+
`Version mismatch: current Tunnel is ${VERSION} and remote Tunnel is ${version}. May cause problems.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default {
|
|
71
|
+
makeOffered,
|
|
72
|
+
makeAccepted,
|
|
73
|
+
isHandshake,
|
|
74
|
+
resetWarnings,
|
|
75
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { fireEvent } from "@testing-library/dom";
|
|
2
|
+
import { wait } from "../promises/wait";
|
|
3
|
+
import { Tunnel } from "./tunnel";
|
|
4
|
+
import { makeAccepted, makeOffered } from "./tunnel-message";
|
|
5
|
+
|
|
6
|
+
const defaultTunnelConfig = {
|
|
7
|
+
targetOrigin: "*",
|
|
8
|
+
timeout: 4000,
|
|
9
|
+
};
|
|
10
|
+
type TunnelHarness = { tunnel: Tunnel; port: MessagePort };
|
|
11
|
+
const openPorts: MessagePort[] = [];
|
|
12
|
+
function tunnelHarness(
|
|
13
|
+
port: MessagePort,
|
|
14
|
+
config = defaultTunnelConfig
|
|
15
|
+
): TunnelHarness {
|
|
16
|
+
const tunnel = new Tunnel(config);
|
|
17
|
+
tunnel.connect(port);
|
|
18
|
+
openPorts.push(port);
|
|
19
|
+
return {
|
|
20
|
+
tunnel,
|
|
21
|
+
port,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function testEventExchange(local: Tunnel, remote: Tunnel) {
|
|
26
|
+
const replyHandler = jest.fn();
|
|
27
|
+
remote.on("outgoing", replyHandler);
|
|
28
|
+
local.on("incoming", (data) => {
|
|
29
|
+
local.emit("outgoing", {
|
|
30
|
+
reply: `${data.greeting} It is I!`,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
remote.emit("incoming", { greeting: "Who goes there?" });
|
|
34
|
+
await wait(10);
|
|
35
|
+
expect(replyHandler).toHaveBeenCalledTimes(1);
|
|
36
|
+
expect(replyHandler.mock.lastCall[0]).toMatchObject({
|
|
37
|
+
reply: "Who goes there? It is I!",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("an EventEmitter dispatching and receiving from a MessagePort", () => {
|
|
42
|
+
let local: TunnelHarness;
|
|
43
|
+
let remote: TunnelHarness;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
const channel = new MessageChannel();
|
|
46
|
+
local = tunnelHarness(channel.port1);
|
|
47
|
+
remote = tunnelHarness(channel.port2);
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
while (openPorts.length > 0) {
|
|
51
|
+
openPorts.pop().close();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it("receives MessageEvents and emits local events to listeners", async () => {
|
|
55
|
+
const test1Handler = jest.fn();
|
|
56
|
+
local.tunnel.on("test1", test1Handler);
|
|
57
|
+
remote.port.postMessage({
|
|
58
|
+
type: "test1",
|
|
59
|
+
payload: {
|
|
60
|
+
test1Payload: true,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
await wait(100);
|
|
64
|
+
expect(test1Handler).toHaveBeenCalled();
|
|
65
|
+
expect(test1Handler.mock.lastCall[0]).toMatchObject({ test1Payload: true });
|
|
66
|
+
});
|
|
67
|
+
it("exchanges connect events", async () => {
|
|
68
|
+
const localConnectHandler = jest.fn();
|
|
69
|
+
const remoteConnectHandler = jest.fn();
|
|
70
|
+
local.tunnel.on("connected", localConnectHandler);
|
|
71
|
+
remote.tunnel.on("connected", remoteConnectHandler);
|
|
72
|
+
await wait(100);
|
|
73
|
+
expect(localConnectHandler).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(remoteConnectHandler).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
it("#emitRemote() sends remote events after connect", async () => {
|
|
77
|
+
const messageListener = jest.fn();
|
|
78
|
+
remote.port.addEventListener("message", messageListener);
|
|
79
|
+
local.tunnel.emit("test2", { test2Payload: true });
|
|
80
|
+
local.tunnel.emit("test3", { test3Payload: true });
|
|
81
|
+
await wait(10);
|
|
82
|
+
expect(messageListener).toHaveBeenCalledTimes(3);
|
|
83
|
+
const connectMessageEvent = messageListener.mock.calls[0][0];
|
|
84
|
+
expect(connectMessageEvent).toHaveProperty("data", {
|
|
85
|
+
type: "connected",
|
|
86
|
+
});
|
|
87
|
+
const test2MessageEvent = messageListener.mock.calls[1][0];
|
|
88
|
+
expect(test2MessageEvent).toHaveProperty("data", {
|
|
89
|
+
type: "test2",
|
|
90
|
+
payload: {
|
|
91
|
+
test2Payload: true,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const test3MessageEvent = messageListener.mock.calls[2][0];
|
|
95
|
+
expect(test3MessageEvent).toHaveProperty("data", {
|
|
96
|
+
type: "test3",
|
|
97
|
+
payload: {
|
|
98
|
+
test3Payload: true,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("exchanges events between two emitters sharing ports", async () => {
|
|
103
|
+
await testEventExchange(local.tunnel, remote.tunnel);
|
|
104
|
+
});
|
|
105
|
+
it("#connect(port) accepts a new messageport", async () => {
|
|
106
|
+
const connectHandler = jest.fn();
|
|
107
|
+
local.tunnel.on("connected", connectHandler);
|
|
108
|
+
remote.tunnel.on("reconnect", connectHandler);
|
|
109
|
+
const confirmHandler = jest.fn();
|
|
110
|
+
local.tunnel.on("confirm", confirmHandler);
|
|
111
|
+
const dispelHandler = jest.fn();
|
|
112
|
+
remote.tunnel.on("dispel", dispelHandler);
|
|
113
|
+
local.tunnel.emit("dispel", { dispelled: 1 });
|
|
114
|
+
remote.tunnel.emit("confirm", { confirmed: 1 });
|
|
115
|
+
await wait(10);
|
|
116
|
+
expect(confirmHandler).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(dispelHandler).toHaveBeenCalledTimes(1);
|
|
118
|
+
|
|
119
|
+
const replacementChannel = new MessageChannel();
|
|
120
|
+
local.tunnel.connect(replacementChannel.port2);
|
|
121
|
+
|
|
122
|
+
// this event should wait until remote connects port1;
|
|
123
|
+
local.tunnel.emit("dispel", { dispelled: 2 });
|
|
124
|
+
|
|
125
|
+
// this event fires on the dead port, since remote.tunnel still has it
|
|
126
|
+
remote.tunnel.emit("confirm", { confirmed: 2 });
|
|
127
|
+
await wait(10);
|
|
128
|
+
// so neither is called
|
|
129
|
+
expect(confirmHandler).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(dispelHandler).toHaveBeenCalledTimes(1);
|
|
131
|
+
|
|
132
|
+
remote.tunnel.connect(replacementChannel.port1);
|
|
133
|
+
await wait(10);
|
|
134
|
+
// dispel handler fired when port1 was opened by #reconnect
|
|
135
|
+
expect(dispelHandler).toHaveBeenCalledTimes(2);
|
|
136
|
+
|
|
137
|
+
// this dispel event should work now
|
|
138
|
+
remote.tunnel.emit("confirm", { confirmed: 3 });
|
|
139
|
+
await wait(10);
|
|
140
|
+
|
|
141
|
+
expect(confirmHandler).toHaveBeenCalledTimes(2);
|
|
142
|
+
expect(confirmHandler.mock.calls[1][0]).toMatchObject({ confirmed: 3 });
|
|
143
|
+
|
|
144
|
+
expect(connectHandler).toHaveBeenCalledTimes(2);
|
|
145
|
+
replacementChannel.port1.close();
|
|
146
|
+
replacementChannel.port2.close();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe("static Tunnel.toIframe(iframe, options)", () => {
|
|
150
|
+
let localTunnel: Tunnel;
|
|
151
|
+
let remoteTunnel: Tunnel;
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
localTunnel && localTunnel.destroy();
|
|
154
|
+
remoteTunnel && remoteTunnel.destroy();
|
|
155
|
+
});
|
|
156
|
+
/**
|
|
157
|
+
* skipped in unit tests because JSDOM's iframe and postMessage don't
|
|
158
|
+
* implement proper MessageEvents as of 2022/11/30. See
|
|
159
|
+
* https://github.com/jsdom/jsdom/blob/22f7c3c51829a6f14387f7a99e5cdf087f72e685/lib/jsdom/living/post-message.js#L31-L37
|
|
160
|
+
*/
|
|
161
|
+
describe.skip("creates a Tunnel connected to an iframe", () => {
|
|
162
|
+
it.only("listens for handshakes from the frame window", async () => {
|
|
163
|
+
let remoteTunnel: Tunnel;
|
|
164
|
+
const connectMessageHandler = jest.fn();
|
|
165
|
+
const acceptListener = jest.fn();
|
|
166
|
+
const loadedFrame = document.createElement("iframe");
|
|
167
|
+
loadedFrame.src = "https://example.com:4001";
|
|
168
|
+
document.body.appendChild(loadedFrame);
|
|
169
|
+
loadedFrame.contentWindow.addEventListener("message", acceptListener);
|
|
170
|
+
const localTunnel = Tunnel.toIframe(loadedFrame, {
|
|
171
|
+
targetOrigin: "https://example.com:4001",
|
|
172
|
+
timeout: 9999,
|
|
173
|
+
});
|
|
174
|
+
localTunnel.on("connected", connectMessageHandler);
|
|
175
|
+
await wait(100);
|
|
176
|
+
fireEvent(
|
|
177
|
+
window,
|
|
178
|
+
new MessageEvent("message", {
|
|
179
|
+
data: makeOffered("iframe-test-1"),
|
|
180
|
+
origin: loadedFrame.src,
|
|
181
|
+
source: loadedFrame.contentWindow,
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
await wait(100);
|
|
185
|
+
expect(acceptListener).toHaveBeenCalled();
|
|
186
|
+
const acceptEvent = acceptListener.mock.lastCall[0];
|
|
187
|
+
expect(acceptEvent).toHaveProperty("data", makeAccepted("iframe-test-1"));
|
|
188
|
+
expect(acceptEvent.ports).toHaveLength(1);
|
|
189
|
+
remoteTunnel = new Tunnel(defaultTunnelConfig);
|
|
190
|
+
remoteTunnel.connect(acceptEvent.ports[0]);
|
|
191
|
+
await wait(100);
|
|
192
|
+
expect(connectMessageHandler).toHaveBeenCalledTimes(1);
|
|
193
|
+
await testEventExchange(localTunnel, remoteTunnel);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import EventEmitter from "eventemitter3";
|
|
2
|
+
import { isIframe, isTunnelSource } from "../value-assertions";
|
|
3
|
+
import {
|
|
4
|
+
isHandshakeAccepting,
|
|
5
|
+
isHandshakeOffer,
|
|
6
|
+
makeAccepted,
|
|
7
|
+
makeOffered,
|
|
8
|
+
} from "./tunnel-message";
|
|
9
|
+
import { unwrap } from "../message-wrapper";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Child iframe will send offer messages to parent at this frequency until one
|
|
13
|
+
* is accepted or the attempt times out.
|
|
14
|
+
* TODO: make configurable if ever necessary
|
|
15
|
+
*/
|
|
16
|
+
const RETRY_MS = 100;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Child iframe may unexpectedly close or detach from DOM. It emits no event
|
|
20
|
+
* when this happens, so we must poll it and destroy the tunnel when necessary.
|
|
21
|
+
* TODO: make configurable if ever necessary
|
|
22
|
+
*/
|
|
23
|
+
const STATUSCHECK_MS = 5000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Semi-unique IDs allow multiple parallel connections to handshake on both parent
|
|
27
|
+
* and child iframe. This generates a semi-random 8-char base 36 string.
|
|
28
|
+
*/
|
|
29
|
+
const KEY_BASE = 36;
|
|
30
|
+
const KEY_LENGTH = 8;
|
|
31
|
+
const KEY_EXP = KEY_BASE ** KEY_LENGTH;
|
|
32
|
+
const makeKey = () => Math.round(Math.random() * KEY_EXP).toString(KEY_BASE);
|
|
33
|
+
|
|
34
|
+
/** @alpha */
|
|
35
|
+
export interface TunnelConfig {
|
|
36
|
+
// #region Properties
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* To ensure secure communication, target origin must be specified, so the
|
|
40
|
+
* tunnel can't connect to an unauthorized domain. Can be '*' to disable
|
|
41
|
+
* origin checks, but this is discouraged!
|
|
42
|
+
*/
|
|
43
|
+
targetOrigin: string;
|
|
44
|
+
/**
|
|
45
|
+
* A Promise for a tunnel will reject if not connected within timeout (ms).
|
|
46
|
+
* @defaultValue 4000
|
|
47
|
+
*/
|
|
48
|
+
timeout: number;
|
|
49
|
+
|
|
50
|
+
// #endregion Properties
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const badTimeout = "\n - timeout value must be a number of milliseconds";
|
|
54
|
+
const badTargetOrigin =
|
|
55
|
+
"\n - targetOrigin must be a valid URL origin or '*' for any origin";
|
|
56
|
+
|
|
57
|
+
function isFromOrigin(
|
|
58
|
+
event: MessageEvent,
|
|
59
|
+
source: WindowProxy,
|
|
60
|
+
targetOrigin: string
|
|
61
|
+
) {
|
|
62
|
+
try {
|
|
63
|
+
return (
|
|
64
|
+
source === event.source &&
|
|
65
|
+
(targetOrigin === "*" || targetOrigin === new URL(event.origin).origin)
|
|
66
|
+
);
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { emit: emitOn } = EventEmitter.prototype;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An EventEmitter across two documents. It emits events on the remote document
|
|
76
|
+
* and takes subscribers from the local document.
|
|
77
|
+
* @alpha
|
|
78
|
+
*/
|
|
79
|
+
export class Tunnel extends EventEmitter {
|
|
80
|
+
// #region Properties
|
|
81
|
+
|
|
82
|
+
private _messagePort: MessagePort;
|
|
83
|
+
|
|
84
|
+
config: TunnelConfig;
|
|
85
|
+
|
|
86
|
+
// #endregion Properties
|
|
87
|
+
|
|
88
|
+
// #region Constructors
|
|
89
|
+
|
|
90
|
+
constructor(config: TunnelConfig) {
|
|
91
|
+
super();
|
|
92
|
+
this.config = config;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// #endregion Constructors
|
|
96
|
+
|
|
97
|
+
// #region Public Static Methods
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a Tunnel that connects to the page running in the provided iframe.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* Returns a Tunnel that listens for connection requests from the page in the
|
|
104
|
+
* provided iframe, which it will send periodically until timeout if that page
|
|
105
|
+
* has called {@link Tunnel.toParent}. If it receives one, the Tunnel will accept the
|
|
106
|
+
* connection and send an exclusive MessagePort to the xrobject on the other
|
|
107
|
+
* end. The tunnel may reconnect if the iframe reloads, in which case it will
|
|
108
|
+
* emit another "connected" event.
|
|
109
|
+
*
|
|
110
|
+
* @alpha
|
|
111
|
+
*/
|
|
112
|
+
static toIframe(
|
|
113
|
+
target: HTMLIFrameElement,
|
|
114
|
+
options: Partial<TunnelConfig>
|
|
115
|
+
): Tunnel {
|
|
116
|
+
if (!isIframe(target)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Provided tunnel target is not an iframe! ${Object.prototype.toString.call(
|
|
119
|
+
target
|
|
120
|
+
)}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const source = target.contentWindow;
|
|
124
|
+
const config = Tunnel._normalizeConfig(options);
|
|
125
|
+
const tunnel = new Tunnel(config);
|
|
126
|
+
let frameStatusCheck: number;
|
|
127
|
+
let timeout: number;
|
|
128
|
+
const offerListener = (event: MessageEvent) => {
|
|
129
|
+
if (
|
|
130
|
+
isFromOrigin(event, source, config.targetOrigin) &&
|
|
131
|
+
isHandshakeOffer(event.data)
|
|
132
|
+
) {
|
|
133
|
+
const accepted = makeAccepted(unwrap(event.data).offers);
|
|
134
|
+
const channel = new MessageChannel();
|
|
135
|
+
source.postMessage(accepted, config.targetOrigin, [channel.port1]);
|
|
136
|
+
tunnel.connect(channel.port2);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const cleanup = () => {
|
|
140
|
+
clearTimeout(timeout);
|
|
141
|
+
clearInterval(frameStatusCheck);
|
|
142
|
+
window.removeEventListener("message", offerListener);
|
|
143
|
+
};
|
|
144
|
+
timeout = window.setTimeout(() => {
|
|
145
|
+
tunnel.emitLocal(
|
|
146
|
+
"error",
|
|
147
|
+
new Error(
|
|
148
|
+
`Timed out awaiting initial message from iframe after ${config.timeout}ms`
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
tunnel.destroy();
|
|
152
|
+
}, config.timeout);
|
|
153
|
+
|
|
154
|
+
tunnel.on("destroyed", cleanup);
|
|
155
|
+
tunnel.on("connected", () => clearTimeout(timeout));
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if the iframe has been unexpectedly removed from the DOM (for
|
|
159
|
+
* example, by React). Unsubscribe event listeners and destroy tunnel.
|
|
160
|
+
*/
|
|
161
|
+
frameStatusCheck = window.setInterval(() => {
|
|
162
|
+
if (!target.isConnected) {
|
|
163
|
+
tunnel.destroy();
|
|
164
|
+
}
|
|
165
|
+
}, STATUSCHECK_MS);
|
|
166
|
+
|
|
167
|
+
window.addEventListener("message", offerListener);
|
|
168
|
+
|
|
169
|
+
return tunnel;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a Tunnel that connects to the page running in the parent window.
|
|
174
|
+
*
|
|
175
|
+
* @remarks
|
|
176
|
+
* Returns a Tunnel that starts sending connection requests to the parent
|
|
177
|
+
* window, sending them periodically until the window responds with an accept
|
|
178
|
+
* message or the timeout passes. The parent window will accept the request if
|
|
179
|
+
* it calls {@link Tunnel.toIframe}.
|
|
180
|
+
*
|
|
181
|
+
* @alpha
|
|
182
|
+
*/
|
|
183
|
+
static toParent(source: WindowProxy, opts: Partial<TunnelConfig>): Tunnel {
|
|
184
|
+
let retrying: number;
|
|
185
|
+
let timeout: number;
|
|
186
|
+
let timedOut = false;
|
|
187
|
+
const key = makeKey();
|
|
188
|
+
const config = Tunnel._normalizeConfig(opts);
|
|
189
|
+
const tunnel = new Tunnel(config);
|
|
190
|
+
const acceptListener = (event: MessageEvent) => {
|
|
191
|
+
if (
|
|
192
|
+
!timedOut &&
|
|
193
|
+
isFromOrigin(event, source, config.targetOrigin) &&
|
|
194
|
+
isHandshakeAccepting(event.data, key)
|
|
195
|
+
) {
|
|
196
|
+
cleanup();
|
|
197
|
+
if (!event.ports || !event.ports.length) {
|
|
198
|
+
const portError = new Error(
|
|
199
|
+
"Received handshake accept message, but it did not include a MessagePort to establish tunnel"
|
|
200
|
+
);
|
|
201
|
+
tunnel.emitLocal("error", portError);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
tunnel.connect(event.ports[0]);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const cleanup = () => {
|
|
208
|
+
clearInterval(retrying);
|
|
209
|
+
clearTimeout(timeout);
|
|
210
|
+
window.removeEventListener("message", acceptListener);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
timeout = window.setTimeout(() => {
|
|
214
|
+
tunnel.emitLocal(
|
|
215
|
+
"error",
|
|
216
|
+
new Error(
|
|
217
|
+
`Timed out waiting for initial response from parent after ${config.timeout}ms`
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
tunnel.destroy();
|
|
221
|
+
}, config.timeout);
|
|
222
|
+
|
|
223
|
+
window.addEventListener("message", acceptListener);
|
|
224
|
+
tunnel.on("destroyed", cleanup);
|
|
225
|
+
tunnel.on("connected", cleanup);
|
|
226
|
+
|
|
227
|
+
const sendOffer = () =>
|
|
228
|
+
source.postMessage(makeOffered(key), config.targetOrigin);
|
|
229
|
+
retrying = window.setInterval(sendOffer, RETRY_MS);
|
|
230
|
+
sendOffer();
|
|
231
|
+
|
|
232
|
+
return tunnel;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// #endregion Public Static Methods
|
|
236
|
+
|
|
237
|
+
// #region Public Methods
|
|
238
|
+
|
|
239
|
+
connect(remote: MessagePort) {
|
|
240
|
+
if (this._messagePort) {
|
|
241
|
+
this._messagePort.removeEventListener("message", this._emitFromMessage);
|
|
242
|
+
this._messagePort.close();
|
|
243
|
+
}
|
|
244
|
+
this._messagePort = remote;
|
|
245
|
+
remote.addEventListener("message", this._emitFromMessage);
|
|
246
|
+
this.emit("connected");
|
|
247
|
+
this._messagePort.start();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
destroy(): void {
|
|
251
|
+
if (this._messagePort) {
|
|
252
|
+
this._messagePort.close();
|
|
253
|
+
this._messagePort = null;
|
|
254
|
+
}
|
|
255
|
+
this.emitLocal("destroyed");
|
|
256
|
+
this.emit("destroyed");
|
|
257
|
+
// this.removeAllListeners(); // TODO: maybe necessary for memory leaks
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
emit(type: string | symbol, payload?: unknown): boolean {
|
|
261
|
+
if (!this._messagePort) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
this._messagePort.postMessage({ type, payload });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
emitLocal = (type: string | symbol, payload?: unknown) => {
|
|
269
|
+
return emitOn.call(this, type, payload);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// #endregion Public Methods
|
|
273
|
+
|
|
274
|
+
// #region Private Static Methods
|
|
275
|
+
|
|
276
|
+
private static _normalizeConfig(
|
|
277
|
+
options: Partial<TunnelConfig> = {}
|
|
278
|
+
): TunnelConfig {
|
|
279
|
+
let errorMessage = "";
|
|
280
|
+
const config: Partial<TunnelConfig> = {
|
|
281
|
+
timeout: 4000,
|
|
282
|
+
...options,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const timeoutMs = Number(config.timeout);
|
|
286
|
+
if (!Number.isSafeInteger(timeoutMs)) {
|
|
287
|
+
errorMessage += badTimeout;
|
|
288
|
+
}
|
|
289
|
+
if (config.targetOrigin !== "*") {
|
|
290
|
+
try {
|
|
291
|
+
new URL(config.targetOrigin);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
errorMessage += badTargetOrigin;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (errorMessage) {
|
|
297
|
+
throw new Error(`Invalid tunnel configuration: ${errorMessage}`);
|
|
298
|
+
}
|
|
299
|
+
return config as TunnelConfig;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// #endregion Private Static Methods
|
|
303
|
+
|
|
304
|
+
// #region Private Methods
|
|
305
|
+
|
|
306
|
+
private _emitFromMessage = ({ data: { type, payload } }: MessageEvent) => {
|
|
307
|
+
this.emitLocal(type, payload);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// #endregion Private Methods
|
|
311
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,8 @@ OF ANY KIND, either express or implied. See the License for the specific languag
|
|
|
10
10
|
governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { CrossRealmObject } from "./cross-realm-object";
|
|
14
|
+
|
|
13
15
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -172,11 +174,7 @@ export interface GuestConnection {
|
|
|
172
174
|
attachUI(
|
|
173
175
|
frame: HTMLIFrameElement,
|
|
174
176
|
privateMethods?: RemoteHostApis
|
|
175
|
-
):
|
|
176
|
-
promise: Promise<unknown>;
|
|
177
|
-
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
178
|
-
destroy: Function;
|
|
179
|
-
};
|
|
177
|
+
): Promise<unknown>;
|
|
180
178
|
load(): Promise<unknown>;
|
|
181
179
|
error?: Error;
|
|
182
180
|
hasCapabilities(capabilities: unknown): boolean;
|