Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .npmrc

This file was deleted.

1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
4 changes: 0 additions & 4 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ nmHoistingLimits: workspaces

nodeLinker: node-modules

npmScopes:
magiclabs:
npmRegistryServer: "https://registry.npmjs.org"
npmAuthToken: "${NPM_TOKEN}"

plugins:
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ These are packages Magic JS SDK uses internally to work seamlessly across platfo
| [`@magic-sdk/types`](https://www.npmjs.com/package/@magic-sdk/types) | [CHANGELOG](./packages/@magic-sdk/types/CHANGELOG.md) | Core typings shared between JavaScript entry-points of Magic SDK. |
| [`@magic-sdk/provider`](https://www.npmjs.com/package/@magic-sdk/provider) | [CHANGELOG](./packages/@magic-sdk/provider/CHANGELOG.md) | Core business logic shared between JavaScript entry-points of Magic SDK. |

## Development requirements

When developing in this monorepo (e.g. after cloning and running `yarn`):

- **Node.js** – LTS or current (see [packageManager](package.json) for the project’s Yarn version).
- **Yarn** – v3.6.0 (use [Corepack](https://nodejs.org/api/corepack.html) or install Yarn 3).
- **Python 3.8+** – Required by node-gyp when building optional native dependencies (e.g. `bufferutil`, `utf-8-validate`). If you use an older Python, installs can still succeed because those builds are skipped via `dependenciesMeta`; only upgrade Python if you need to build other native addons.

## 🚦 Testing

Run tests for all packages
Expand Down
1 change: 1 addition & 0 deletions packages/@magic-sdk/provider/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function getNetworkHash(apiKey: string, network?: EthNetworkConfiguration, extCo
// Custom network, not necessarily eth.
return `${apiKey}_${network.rpcUrl}_${network.chainId}_${network.chainType}`;
}
/* istanbul ignore next -- unreachable under valid EthNetworkConfiguration */
return `${apiKey}_unknown`;
}

Expand Down
55 changes: 28 additions & 27 deletions packages/@magic-sdk/provider/src/core/view-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,9 @@ export abstract class ViewController {
protected isConnectedToInternet = true;
protected lastPongTime: null | number = null;
protected heartbeatIntervalTimer: ReturnType<typeof setInterval> | null = null;
/* istanbul ignore next */

protected heartbeatDebounce = debounce(() => {
// Only do this for web now
if (this.endpoint === 'https://auth.magic.link/') {
this.heartBeatCheck();
}
}, INITIAL_HEARTBEAT_DELAY);

protected thirdPartyWalletRequestHandler: (event: MagicThirdPartyWalletRequest) => any = () => {};
Expand Down Expand Up @@ -97,6 +94,7 @@ export abstract class ViewController {
if (!this.isConnectedToInternet) {
const error = createModalNotReadyError();
reject(error);
return;
}

if (!(await this.checkRelayerExistsInDOM())) {
Expand Down Expand Up @@ -215,7 +213,6 @@ export abstract class ViewController {
// We cannot effectively cover this function because it never gets reference
// by value. The functionality of this callback is tested within
// `initMessageListener`.
/* istanbul ignore next */
const listener = (event: MagicMessageEvent) => {
if (event.data.msgType === `${msgType}-${this.parameters}`) boundHandler(event);
};
Expand Down Expand Up @@ -261,7 +258,6 @@ export abstract class ViewController {
* Sends periodic pings to check the connection.
* If no pong is received or it’s stale, the iframe is reloaded.
*/
/* istanbul ignore next */
private heartBeatCheck() {
let firstPing = true;

Expand All @@ -275,34 +271,39 @@ export abstract class ViewController {
};

this.heartbeatIntervalTimer = setInterval(async () => {
// If no pong has ever been received.
if (!this.lastPongTime) {
if (!firstPing) {
// On subsequent ping with no previous pong response, reload the iframe.
this.reloadRelayer();
firstPing = true;
return;
}
} else {
// If we have a pong, check how long ago it was received.
const timeSinceLastPong = Date.now() - this.lastPongTime;
if (timeSinceLastPong > PING_INTERVAL * 2) {
// If the pong is too stale, reload the iframe.
this.reloadRelayer();
firstPing = true;
return;
try {
// If no pong has ever been received.
if (!this.lastPongTime) {
if (!firstPing) {
// On subsequent ping with no previous pong response, reload the iframe.
this.reloadRelayer();
firstPing = true;
return;
}
} else {
// If we have a pong, check how long ago it was received.
const timeSinceLastPong = Date.now() - this.lastPongTime;
if (timeSinceLastPong > PING_INTERVAL * 2) {
// If the pong is too stale, reload the iframe.
this.reloadRelayer();
firstPing = true;
return;
}
}
}

// Send a new ping message and update the counter.
await sendPing();
firstPing = false;
// Send a new ping message and update the counter.
await sendPing();
firstPing = false;
} catch {
// _post failed (e.g. iframe gone); reload to recover.
this.reloadRelayer();
firstPing = true;
}
}, PING_INTERVAL);
}

// Debounce revival mechanism
// Kill any existing PingPong interval
/* istanbul ignore next */
protected stopHeartBeat() {
this.heartbeatDebounce();
this.lastPongTime = null;
Expand Down
2 changes: 1 addition & 1 deletion packages/@magic-sdk/provider/src/util/get-payload-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ function* createIntGenerator(): Generator<number, number, void> {
let index = 0;

while (true) {
/* istanbul ignore next */
/* istanbul ignore else -- edge case: reset after MAX_SAFE_INTEGER, impractical to test */
if (index < Number.MAX_SAFE_INTEGER) yield ++index;
else index = 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { JsonRpcError, JsonRpcRequestPayload, RPCErrorCode } from '@magic-sdk/types';
import { JsonRpcResponse } from '../../../../../src/core/json-rpc';
import * as webCrypto from '../../../../../src/util/web-crypto';

jest.mock('../../../../../src/util/web-crypto', () => ({
clearKeys: jest.fn(),
Expand Down Expand Up @@ -96,7 +97,7 @@ test('Does not call clearKeys when error exists but has no code property', () =>
const response = new JsonRpcResponse(payload);
// Apply an error-like object without a code property
response.applyError({ message: 'Some error' } as any);

expect(response.hasError).toBe(true);
expect(clearKeys).not.toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ test('Initialize `MagicSDK` with custom network object', () => {

expect(magic.apiKey).toBe(TEST_API_KEY);
expect(magic.endpoint).toBe(MAGIC_RELAYER_FULL_URL);
expect(magic.networkHash).toBe(`${TEST_API_KEY}_https://custom.rpc.url_12345_eth`);
expect((magic as any).networkHash).toBe(`${TEST_API_KEY}_https://custom.rpc.url_12345_eth`);
assertModuleInstanceTypes(magic);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,15 @@ test('Instantiates `ViewController`', async () => {
expect(overlay.parameters).toBe('qwerty');
expect(listenStub).toBeCalledTimes(1);
});

test('onThirdPartyWalletRequest stores the handler', () => {
const listenStub = jest.fn();
(ViewController.prototype as any).listen = listenStub;

const overlay = new (ViewController as any)('testing123', 'qwerty');
const handler = jest.fn();

ViewController.prototype.onThirdPartyWalletRequest.call(overlay, handler);

expect((overlay as any).thirdPartyWalletRequestHandler).toBe(handler);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { MagicOutgoingWindowMessage } from '@magic-sdk/types';
import { createViewController } from '../../../factories';
import { ENCODED_QUERY_PARAMS } from '../../../constants';

const PING_INTERVAL = 5 * 60 * 1000; // 5 minutes
const INITIAL_HEARTBEAT_DELAY = 60 * 60 * 1000; // 1 hour

beforeEach(() => {
jest.useFakeTimers();
jest.resetAllMocks();
});

afterEach(() => {
jest.useRealTimers();
});

test('stopHeartBeat clears heartbeat interval and invokes debounced callback', () => {
const viewController = createViewController('');
viewController.reloadRelayer = jest.fn().mockResolvedValue(undefined);
viewController._post = jest.fn().mockResolvedValue(undefined);

viewController.stopHeartBeat();
expect((viewController as any).heartbeatIntervalTimer).toBeNull();
expect((viewController as any).lastPongTime).toBeNull();
});

test('heartBeatCheck runs after debounce delay and sends ping on interval', async () => {
const viewController = createViewController('');
viewController.reloadRelayer = jest.fn().mockResolvedValue(undefined);
viewController._post = jest.fn().mockResolvedValue(undefined);

viewController.stopHeartBeat();
jest.advanceTimersByTime(INITIAL_HEARTBEAT_DELAY);

await Promise.resolve();
expect((viewController as any).heartbeatIntervalTimer).not.toBeNull();

jest.advanceTimersByTime(PING_INTERVAL);
await Promise.resolve();
expect(viewController._post).toHaveBeenCalledWith({
msgType: `${MagicOutgoingWindowMessage.MAGIC_PING}-${ENCODED_QUERY_PARAMS}`,
payload: [],
});
});

test('heartBeatCheck reloads relayer when no pong received on second interval tick', async () => {
const viewController = createViewController('');
viewController.reloadRelayer = jest.fn().mockResolvedValue(undefined);
viewController._post = jest.fn().mockResolvedValue(undefined);

viewController.stopHeartBeat();
jest.advanceTimersByTime(INITIAL_HEARTBEAT_DELAY);
await Promise.resolve();
await Promise.resolve();

jest.advanceTimersByTime(PING_INTERVAL);
await Promise.resolve();
await Promise.resolve();
jest.advanceTimersByTime(PING_INTERVAL);
await Promise.resolve();
await Promise.resolve();

expect(viewController.reloadRelayer).toHaveBeenCalled();
});

test('heartBeatCheck reloads relayer when pong is stale', async () => {
const viewController = createViewController('');
viewController.reloadRelayer = jest.fn().mockResolvedValue(undefined);
viewController._post = jest.fn().mockResolvedValue(undefined);

viewController.stopHeartBeat();
jest.advanceTimersByTime(INITIAL_HEARTBEAT_DELAY);
await Promise.resolve();

(viewController as any).lastPongTime = Date.now() - PING_INTERVAL * 3;
jest.advanceTimersByTime(PING_INTERVAL);
await Promise.resolve();

expect(viewController.reloadRelayer).toHaveBeenCalled();
});

test('heartBeatCheck reloads relayer when _post throws', async () => {
const viewController = createViewController('');
viewController.reloadRelayer = jest.fn().mockResolvedValue(undefined);
viewController._post = jest.fn().mockRejectedValue(new Error('post failed'));

viewController.stopHeartBeat();
jest.advanceTimersByTime(INITIAL_HEARTBEAT_DELAY);
await Promise.resolve();
await Promise.resolve();

jest.advanceTimersByTime(PING_INTERVAL);
await Promise.resolve();
await Promise.resolve();

expect(viewController.reloadRelayer).toHaveBeenCalled();
});
Original file line number Diff line number Diff line change
Expand Up @@ -451,3 +451,29 @@ test('Async, with batch payload having null id in success response', done => {
magic.rpcProvider.sendAsync([payload1, payload2], onRequestComplete);
});

test('Async, with batch payload having null jsonrpc in success response', done => {
const magic = createMagicSDK();

const payload1 = { method: 'eth_call', params: ['hello world'] };
const payload2 = { method: 'eth_call', params: ['goodbye world'] };

let callCount = 0;
const postStub = jest.fn().mockImplementation((msgType, requestPayload: any) => {
callCount++;
// Mutate to null so response builder hits p.jsonrpc ?? '2.0' branch
requestPayload.jsonrpc = null;
const response = new JsonRpcResponse(requestPayload);
return Promise.resolve(response.applyResult(`test${callCount}`));
});
magic.rpcProvider.overlay.post = postStub;

const onRequestComplete = jest.fn((_, responses) => {
expect(_).toBe(null);
expect(responses.length).toBe(2);
expect(responses[0].jsonrpc).toBe('2.0');
expect(responses[1].jsonrpc).toBe('2.0');
done();
});
magic.rpcProvider.sendAsync([payload1, payload2], onRequestComplete);
});

Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UserEventsEmit, UserEventsOnReceived } from '@magic-sdk/types';
import { createPromiEvent } from '../../../../src/util/promise-tools';
import { TypedEmitter } from '../../../../src/util/events';

Expand Down Expand Up @@ -105,14 +106,12 @@ test('Emits "settled" event upon Promise reject', done => {
});

test('Emits ClosedByUser event when UserEventsOnReceived.ClosedByUser is received', done => {
const { UserEventsOnReceived, UserEventsEmit } = require('@magic-sdk/types');
const promiEvent = createPromiEvent(resolve => resolve(true));

promiEvent.on(UserEventsEmit.ClosedByUser, () => {
done();
});

// Simulate receiving the ClosedByUser event from the iframe
// The handler should emit UserEventsEmit.ClosedByUser
(promiEvent as any).emit(UserEventsOnReceived.ClosedByUser);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { debounce } from '../../../src/util/view-controller-utils';

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

test('debounce calls the function after the delay', () => {
const fn = jest.fn();
const debounced = debounce(fn, 100);

debounced();
expect(fn).not.toHaveBeenCalled();

jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});

test('debounce clears previous timeout when called multiple times quickly', () => {
const fn = jest.fn();
const debounced = debounce(fn, 100);

debounced();
debounced();
debounced();

jest.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});

test('debounce passes arguments to the function', () => {
const fn = jest.fn();
const debounced = debounce(fn, 100);

debounced('a', 'b');
jest.advanceTimersByTime(100);

expect(fn).toHaveBeenCalledWith('a', 'b');
});
4 changes: 2 additions & 2 deletions packages/@magic-sdk/react-native-bare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"types": "./dist/types/index.d.ts",
"dependencies": {
"@aveq-research/localforage-asyncstorage-driver": "^3.0.1",
"@magic-sdk/provider": "^31.2.0",
"@magic-sdk/provider": "^33.3.1-canary.1018.21526736144.0",
"@magic-sdk/types": "^25.2.0",
"@react-native-async-storage/async-storage": "^2.1.2",
"@types/lodash": "^4.14.158",
Expand All @@ -40,7 +40,7 @@
"@react-native/babel-preset": "^0.79.0",
"@testing-library/react-native": "^13.2.0",
"react": "~19.1.0",
"react-native": "~0.78.1",
"react-native": "^0.83.1",
"react-native-device-info": "^10.3.0",
"react-native-keychain": "^10.0.0",
"react-native-safe-area-context": "5.3.0",
Expand Down
Loading