Skip to content

Commit 41a8c7c

Browse files
authoredMar 22, 2025··
Merge pull request #863 from magiclabs/check-if-iframe-has-been-removed
implement checkIframeExists
2 parents 708ae8d + 4209615 commit 41a8c7c

File tree

4 files changed

+124
-47
lines changed

4 files changed

+124
-47
lines changed
 

‎packages/@magic-sdk/provider/src/core/sdk-exceptions.ts

+25-6
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { Extension } from '../modules/base-extension';
1313
export class MagicSDKError extends Error {
1414
__proto__ = Error;
1515

16-
constructor(public code: SDKErrorCode, public rawMessage: string) {
16+
constructor(
17+
public code: SDKErrorCode,
18+
public rawMessage: string,
19+
) {
1720
super(`Magic SDK Error: [${code}] ${rawMessage}`);
1821
Object.setPrototypeOf(this, MagicSDKError.prototype);
1922
}
@@ -51,7 +54,10 @@ export class MagicRPCError extends Error {
5154
export class MagicSDKWarning {
5255
public message: string;
5356

54-
constructor(public code: SDKWarningCode, public rawMessage: string) {
57+
constructor(
58+
public code: SDKWarningCode,
59+
public rawMessage: string,
60+
) {
5561
this.message = `Magic SDK Warning: [${code}] ${rawMessage}`;
5662
}
5763

@@ -71,7 +77,12 @@ export class MagicSDKWarning {
7177
export class MagicExtensionError<TData = any> extends Error {
7278
__proto__ = Error;
7379

74-
constructor(ext: Extension<string>, public code: string | number, public rawMessage: string, public data: TData) {
80+
constructor(
81+
ext: Extension<string>,
82+
public code: string | number,
83+
public rawMessage: string,
84+
public data: TData,
85+
) {
7586
super(`Magic Extension Error (${ext.name}): [${code}] ${rawMessage}`);
7687
Object.setPrototypeOf(this, MagicExtensionError.prototype);
7788
}
@@ -85,7 +96,11 @@ export class MagicExtensionError<TData = any> extends Error {
8596
export class MagicExtensionWarning {
8697
public message: string;
8798

88-
constructor(ext: Extension<string>, public code: string | number, public rawMessage: string) {
99+
constructor(
100+
ext: Extension<string>,
101+
public code: string | number,
102+
public rawMessage: string,
103+
) {
89104
this.message = `Magic Extension Warning (${ext.name}): [${code}] ${rawMessage}`;
90105
}
91106

@@ -110,6 +125,10 @@ export function createModalNotReadyError() {
110125
return new MagicSDKError(SDKErrorCode.ModalNotReady, 'Modal is not ready.');
111126
}
112127

128+
export function createModalLostError() {
129+
return new MagicSDKError(SDKErrorCode.ConnectionLost, 'Modal Disconnected, Reinitializing. please try again.');
130+
}
131+
113132
export function createMalformedResponseError() {
114133
return new MagicSDKError(SDKErrorCode.MalformedResponse, 'Response from the Magic iframe is malformed.');
115134
}
@@ -125,8 +144,8 @@ export function createIncompatibleExtensionsError(extensions: Extension<string>[
125144
let msg = `Some extensions are incompatible with \`${SDKEnvironment.sdkName}@${SDKEnvironment.version}\`:`;
126145

127146
extensions
128-
.filter((ext) => typeof ext.compat !== 'undefined' && ext.compat !== null)
129-
.forEach((ext) => {
147+
.filter(ext => typeof ext.compat !== 'undefined' && ext.compat !== null)
148+
.forEach(ext => {
130149
const compat = ext.compat![SDKEnvironment.sdkName];
131150

132151
/* istanbul ignore else */

‎packages/@magic-sdk/types/src/core/exception-types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum SDKErrorCode {
22
MissingApiKey = 'MISSING_API_KEY',
33
ModalNotReady = 'MODAL_NOT_READY',
4+
ConnectionLost = 'CONNECTION_WAS_LOST',
45
MalformedResponse = 'MALFORMED_RESPONSE',
56
InvalidArgument = 'INVALID_ARGUMENT',
67
ExtensionNotInitialized = 'EXTENSION_NOT_INITIALIZED',

‎packages/magic-sdk/src/iframe-controller.ts

+94-38
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { ViewController, createDuplicateIframeWarning, createURL, createModalNotReadyError } from '@magic-sdk/provider';
1+
import {
2+
createDuplicateIframeWarning,
3+
createModalLostError,
4+
createModalNotReadyError,
5+
createURL,
6+
ViewController,
7+
} from '@magic-sdk/provider';
28
import { MagicIncomingWindowMessage, MagicOutgoingWindowMessage } from '@magic-sdk/types';
39

410
/**
@@ -45,8 +51,7 @@ function checkForSameSrcInstances(parameters: string) {
4551

4652
const SECOND = 1000;
4753
const MINUTE = 60 * SECOND;
48-
const RESPONSE_DELAY = 15 * SECOND; // 15 seconds
49-
const PING_INTERVAL = 2 * MINUTE; // 2 minutes
54+
const PING_INTERVAL = 5 * MINUTE; // 5 minutes
5055
const INITIAL_HEARTBEAT_DELAY = 60 * MINUTE; // 1 hour
5156

5257
/**
@@ -55,9 +60,9 @@ const INITIAL_HEARTBEAT_DELAY = 60 * MINUTE; // 1 hour
5560
export class IframeController extends ViewController {
5661
private iframe!: Promise<HTMLIFrameElement>;
5762
private activeElement: any = null;
58-
private lastPingTime = Date.now();
59-
private intervalTimer: ReturnType<typeof setInterval> | null = null;
60-
private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
63+
private lastPongTime: null | number = null;
64+
private heartbeatIntervalTimer: ReturnType<typeof setInterval> | null = null;
65+
private heartbeatDebounce = debounce(() => this.heartBeatCheck(), INITIAL_HEARTBEAT_DELAY);
6166

6267
private getIframeSrc() {
6368
return createURL(`/send?params=${encodeURIComponent(this.parameters)}`, this.endpoint).href;
@@ -97,23 +102,23 @@ export class IframeController extends ViewController {
97102
this.iframe.then(iframe => {
98103
if (iframe instanceof HTMLIFrameElement) {
99104
iframe.addEventListener('load', async () => {
100-
await this.startHeartBeat();
105+
this.heartbeatDebounce();
101106
});
102107
}
103108
});
104109

105110
window.addEventListener('message', (event: MessageEvent) => {
106-
if (event.origin === this.endpoint) {
107-
if (event.data && event.data.msgType && this.messageHandlers.size) {
108-
const isPongMessage = event.data.msgType.includes(MagicIncomingWindowMessage.MAGIC_PONG);
111+
if (event.origin === this.endpoint && event.data.msgType) {
112+
if (event.data.msgType.includes(MagicIncomingWindowMessage.MAGIC_PONG)) {
113+
// Mark the Pong time
114+
this.lastPongTime = Date.now();
115+
}
109116

110-
if (isPongMessage) {
111-
this.lastPingTime = Date.now();
112-
}
117+
if (event.data && this.messageHandlers.size) {
113118
// If the response object is undefined, we ensure it's at least an
114119
// empty object before passing to the event listener.
115-
/* istanbul ignore next */
116120
event.data.response = event.data.response ?? {};
121+
this.stopHeartBeat();
117122
for (const handler of this.messageHandlers.values()) {
118123
handler(event);
119124
}
@@ -145,56 +150,107 @@ export class IframeController extends ViewController {
145150
}
146151

147152
protected async _post(data: any) {
148-
const iframe = await this.iframe;
153+
const iframe = await this.checkIframeExistsInDOM();
154+
155+
if (!iframe) {
156+
this.init();
157+
throw createModalLostError();
158+
}
159+
149160
if (iframe && iframe.contentWindow) {
150161
iframe.contentWindow.postMessage(data, this.endpoint);
151162
} else {
152163
throw createModalNotReadyError();
153164
}
154165
}
155166

167+
/**
168+
* Sends periodic pings to check the connection.
169+
* If no pong is received or it’s stale, the iframe is reloaded.
170+
*/
171+
/* istanbul ignore next */
156172
private heartBeatCheck() {
157-
this.intervalTimer = setInterval(async () => {
158-
const message = { msgType: `${MagicOutgoingWindowMessage.MAGIC_PING}-${this.parameters}`, payload: [] };
173+
let firstPing = true;
159174

175+
// Helper function to send a ping message.
176+
const sendPing = async () => {
177+
const message = {
178+
msgType: `${MagicOutgoingWindowMessage.MAGIC_PING}-${this.parameters}`,
179+
payload: [],
180+
};
160181
await this._post(message);
182+
};
161183

162-
const timeSinceLastPing = Date.now() - this.lastPingTime;
163-
164-
if (timeSinceLastPing > RESPONSE_DELAY) {
165-
await this.reloadIframe();
166-
this.lastPingTime = Date.now();
184+
this.heartbeatIntervalTimer = setInterval(async () => {
185+
// If no pong has ever been received.
186+
if (!this.lastPongTime) {
187+
if (!firstPing) {
188+
// On subsequent ping with no previous pong response, reload the iframe.
189+
this.reloadIframe();
190+
firstPing = true;
191+
return;
192+
}
193+
} else {
194+
// If we have a pong, check how long ago it was received.
195+
const timeSinceLastPong = Date.now() - this.lastPongTime;
196+
if (timeSinceLastPong > PING_INTERVAL * 2) {
197+
// If the pong is too stale, reload the iframe.
198+
this.reloadIframe();
199+
firstPing = true;
200+
return;
201+
}
167202
}
168-
}, PING_INTERVAL);
169-
}
170203

171-
private async startHeartBeat() {
172-
const iframe = await this.iframe;
173-
174-
if (iframe) {
175-
this.timeoutTimer = setTimeout(() => this.heartBeatCheck(), INITIAL_HEARTBEAT_DELAY);
176-
}
204+
// Send a new ping message and update the counter.
205+
await sendPing();
206+
firstPing = false;
207+
}, PING_INTERVAL);
177208
}
178209

210+
// Debounce revival mechanism
211+
// Kill any existing PingPong interval
179212
private stopHeartBeat() {
180-
if (this.timeoutTimer) {
181-
clearTimeout(this.timeoutTimer);
182-
this.timeoutTimer = null;
183-
}
213+
this.heartbeatDebounce();
214+
this.lastPongTime = null;
184215

185-
if (this.intervalTimer) {
186-
clearInterval(this.intervalTimer);
187-
this.intervalTimer = null;
216+
if (this.heartbeatIntervalTimer) {
217+
clearInterval(this.heartbeatIntervalTimer);
218+
this.heartbeatIntervalTimer = null;
188219
}
189220
}
190221

191222
private async reloadIframe() {
192223
const iframe = await this.iframe;
193224

225+
// Reset HeartBeat
226+
this.stopHeartBeat();
227+
194228
if (iframe) {
229+
// reload the iframe source
195230
iframe.src = this.getIframeSrc();
196231
} else {
197-
throw createModalNotReadyError();
232+
this.init();
233+
console.warn('Magic SDK: Modal lost, re-initiating');
198234
}
199235
}
236+
237+
async checkIframeExistsInDOM() {
238+
// Check if the iframe is already in the DOM
239+
const iframes: HTMLIFrameElement[] = [].slice.call(document.querySelectorAll('.magic-iframe'));
240+
return iframes.find(iframe => iframe.src.includes(encodeURIComponent(this.parameters)));
241+
}
242+
}
243+
244+
function debounce<T extends (...args: unknown[]) => void>(func: T, delay: number) {
245+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
246+
247+
return function (...args: Parameters<T>): void {
248+
if (timeoutId) {
249+
clearTimeout(timeoutId);
250+
}
251+
252+
timeoutId = setTimeout(() => {
253+
func(...args);
254+
}, delay);
255+
};
200256
}

‎packages/magic-sdk/test/spec/iframe-controller/_post.spec.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import browserEnv from '@ikscodes/browser-env';
2-
import { createModalNotReadyError } from '@magic-sdk/provider';
2+
import { createModalLostError } from '@magic-sdk/provider';
33
import { createIframeController } from '../../factories';
44

55
beforeEach(() => {
@@ -13,7 +13,8 @@ test('Calls iframe.contentWindow.postMessage with the expected arguments', async
1313
const overlay = createIframeController('http://example.com');
1414

1515
const postMessageStub = jest.fn();
16-
(overlay as any).iframe = { contentWindow: { postMessage: postMessageStub } };
16+
17+
overlay.checkIframeExistsInDOM = jest.fn().mockResolvedValue({ contentWindow: { postMessage: postMessageStub } });
1718

1819
await (overlay as any)._post({ thisIsData: 'hello world' });
1920

@@ -25,6 +26,6 @@ test('Throws MODAL_NOT_READY error if iframe.contentWindow is nil', async () =>
2526

2627
(overlay as any).iframe = undefined;
2728

28-
const expectedError = createModalNotReadyError();
29+
const expectedError = createModalLostError();
2930
expect(() => (overlay as any)._post({ thisIsData: 'hello world' })).rejects.toThrow(expectedError);
3031
});

0 commit comments

Comments
 (0)
Please sign in to comment.