@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.
Files changed (91) hide show
  1. package/dist/__helpers__/jest.messagechannel.d.cts +2 -0
  2. package/dist/__helpers__/jest.messagechannel.d.cts.map +1 -0
  3. package/dist/__mocks__/mock-finalization-registry.d.ts +11 -0
  4. package/dist/__mocks__/mock-finalization-registry.d.ts.map +1 -0
  5. package/dist/__mocks__/mock-weak-ref.d.ts +7 -0
  6. package/dist/__mocks__/mock-weak-ref.d.ts.map +1 -0
  7. package/dist/constants.d.ts +7 -0
  8. package/dist/constants.d.ts.map +1 -0
  9. package/dist/cross-realm-object.d.ts +44 -0
  10. package/dist/cross-realm-object.d.ts.map +1 -0
  11. package/dist/debuglog.d.ts +11 -0
  12. package/dist/debuglog.d.ts.map +1 -1
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +817 -7
  16. package/dist/index.js.map +1 -1
  17. package/dist/message-wrapper.d.ts +9 -0
  18. package/dist/message-wrapper.d.ts.map +1 -0
  19. package/dist/object-simulator.d.ts +28 -0
  20. package/dist/object-simulator.d.ts.map +1 -0
  21. package/dist/object-simulator.test.d.ts +2 -0
  22. package/dist/object-simulator.test.d.ts.map +1 -0
  23. package/dist/object-walker.d.ts +29 -0
  24. package/dist/object-walker.d.ts.map +1 -0
  25. package/dist/promises/index.d.ts +3 -0
  26. package/dist/promises/index.d.ts.map +1 -0
  27. package/dist/promises/promise-wrappers.test.d.ts +2 -0
  28. package/dist/promises/promise-wrappers.test.d.ts.map +1 -0
  29. package/dist/promises/timed.d.ts +15 -0
  30. package/dist/promises/timed.d.ts.map +1 -0
  31. package/dist/promises/wait.d.ts +7 -0
  32. package/dist/promises/wait.d.ts.map +1 -0
  33. package/dist/remote-subject.d.ts +70 -0
  34. package/dist/remote-subject.d.ts.map +1 -0
  35. package/dist/rpc/call-receiver.d.ts +4 -0
  36. package/dist/rpc/call-receiver.d.ts.map +1 -0
  37. package/dist/rpc/call-receiver.test.d.ts +2 -0
  38. package/dist/rpc/call-receiver.test.d.ts.map +1 -0
  39. package/dist/rpc/call-sender.d.ts +4 -0
  40. package/dist/rpc/call-sender.d.ts.map +1 -0
  41. package/dist/rpc/call-sender.test.d.ts +2 -0
  42. package/dist/rpc/call-sender.test.d.ts.map +1 -0
  43. package/dist/rpc/index.d.ts +3 -0
  44. package/dist/rpc/index.d.ts.map +1 -0
  45. package/dist/tickets.d.ts +34 -0
  46. package/dist/tickets.d.ts.map +1 -0
  47. package/dist/tunnel/index.d.ts +2 -0
  48. package/dist/tunnel/index.d.ts.map +1 -0
  49. package/dist/tunnel/tunnel-message.d.ts +19 -0
  50. package/dist/tunnel/tunnel-message.d.ts.map +1 -0
  51. package/dist/tunnel/tunnel.d.ts +58 -0
  52. package/dist/tunnel/tunnel.d.ts.map +1 -0
  53. package/dist/tunnel/tunnel.test.d.ts +2 -0
  54. package/dist/tunnel/tunnel.test.d.ts.map +1 -0
  55. package/dist/types.d.ts +1 -4
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/value-assertions.d.ts +10 -0
  58. package/dist/value-assertions.d.ts.map +1 -0
  59. package/package.json +4 -1
  60. package/src/__helpers__/jest.messagechannel.cjs +3 -0
  61. package/src/__mocks__/mock-finalization-registry.ts +13 -0
  62. package/src/__mocks__/mock-weak-ref.ts +10 -0
  63. package/src/constants.ts +6 -0
  64. package/src/cross-realm-object.ts +117 -0
  65. package/src/debuglog.ts +1 -1
  66. package/src/index.ts +4 -1
  67. package/src/message-wrapper.ts +35 -0
  68. package/src/object-simulator.test.ts +135 -0
  69. package/src/object-simulator.ts +136 -0
  70. package/src/object-walker.ts +92 -0
  71. package/src/promises/index.ts +2 -0
  72. package/src/promises/promise-wrappers.test.ts +63 -0
  73. package/src/promises/timed.ts +41 -0
  74. package/src/promises/wait.ts +10 -0
  75. package/src/remote-subject.ts +185 -0
  76. package/src/rpc/call-receiver.test.ts +90 -0
  77. package/src/rpc/call-receiver.ts +29 -0
  78. package/src/rpc/call-sender.test.ts +73 -0
  79. package/src/rpc/call-sender.ts +72 -0
  80. package/src/rpc/index.ts +2 -0
  81. package/src/tickets.ts +71 -0
  82. package/src/tunnel/index.ts +1 -0
  83. package/src/tunnel/tunnel-message.ts +75 -0
  84. package/src/tunnel/tunnel.test.ts +196 -0
  85. package/src/tunnel/tunnel.ts +311 -0
  86. package/src/types.ts +3 -5
  87. package/src/value-assertions.ts +48 -0
  88. package/tsconfig.json +2 -6
  89. package/dist/timeout-promise.d.ts +0 -12
  90. package/dist/timeout-promise.d.ts.map +0 -1
  91. 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;