Skip to content

Commit

Permalink
Add hot reload (#26)
Browse files Browse the repository at this point in the history
* Add hot reload

* Fix enableHotReload test

* Fix tests

* Fix tests

* Refactor

* Revert style changes

* Fix socket address

* Fix ws address

test structure

WIP: websocket test

WIP: hot realod websocket test

Remove test

* update docs

* adds tests

* adds component

* WIP: working test on port 3001

* Fix test, not time dependend now

* Move hotReload to config

* Single param for hotReload

* adds config object

* clean up

* fix readme

* formatting

* formatting

---------

Co-authored-by: Elliot Braem <[email protected]>
  • Loading branch information
bb-face and elliotBraem authored Jul 10, 2024
1 parent d16678e commit a3958f1
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 49 deletions.
47 changes: 40 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,30 @@ yarn serve prod

The `near-social-viewer` web component supports several attributes:

- `src`: the src of the widget to render (e.g. `devs.near/widget/default`)
- `code`: raw, valid, stringified widget code to render (e.g. `"return <p>hello world</p>"`)
- `initialprops`: initial properties to be passed to the rendered widget.
- `rpc`: rpc url to use for requests within the VM
- `network`: network to connect to for rpc requests & wallet connection
* `src`: the src of the widget to render (e.g. `devs.near/widget/default`)
* `code`: raw, valid, stringified widget code to render (e.g. `"return <p>hello world</p>"`)
* `initialprops`: initial properties to be passed to the rendered widget
* `rpc`: rpc url to use for requests within the VM
* `network`: network to connect to for rpc requests & wallet connection
* `config`: options to modify the underlying VM or usage with devtools, see available [configurations](#configuration-options)

## Configuration Options

To support specific features of the VM or an accompanying development server, provide a configuration following this structure:

```jsonc
{
"dev": {
// Configuration options dedicated to the development server
"hotreload": {
"enabled": boolean, // Determines if hot reload is enabled (e.g., true)
"wss": string // WebSocket server URL to connect to. Optional. Defaults to `ws://${window.location.host}` (e.g., "ws://localhost:3001")
}
}
}
```

## Configuring VM Custom Elements
## Adding VM Custom Elements

Since [NearSocial/VM v2.1.0](https://github.com/NearSocial/VM/blob/master/CHANGELOG.md#210), a gateway can register custom elements where the key is the name of the element, and the value is a function that returns a React component. For example:

Expand Down Expand Up @@ -126,7 +143,17 @@ yarn test:ui:codespaces

In general it is a good practice, and very helpful for reviewers and users of this project, that all use cases are covered in Playwright tests. Also, when contributing, try to make your tests as simple and clear as possible, so that they serve as examples on how to use the functionality.

## Use redirectmap for development
## Local Widget Development

There are several strategies for accessing local widget code during development.

### Proxy RPC

The recommended, least invasive strategy is to provide a custom RPC url that proxies requests for widget code. Widget code is stored in the [socialdb](https://github.com/NearSocial/social-db), and so it involves an RPC request to get the stringified code. We can proxy this request to use our local code instead.

You can build a custom proxy server, or [bos-workspace](https://github.com/nearbuilders/bos-workspace) provides a proxy by default and will automatically inject it to the `rpc` attribute if you provide the path to your web component's dist, or a link to it stored on [NEARFS](https://github.com/vgrichina/nearfs). See more in [Customizing the Gateway](https://github.com/NEARBuilders/bos-workspace?tab=readme-ov-file#customizing-the-gateway).

### Redirect Map

The NEAR social VM supports a feature called `redirectMap` which allows you to load widgets from other sources than the on chain social db. An example redirect map can look like this:

Expand All @@ -142,6 +169,12 @@ By setting the session storage key `nearSocialVMredirectMap` to the JSON value o

You can also use the same mechanism as [near-discovery](https://github.com/near/near-discovery/) where you can load components from a locally hosted [bos-loader](https://github.com/near/bos-loader) by adding the key `flags` to localStorage with the value `{"bosLoaderUrl": "http://127.0.0.1:3030" }`.

### Hot Reload

The above strategies require changes to be reflected either on page reload, or from a fresh rpc request. For faster updates, there is an option in `config` to enable hot reload via dev.hotreload (see [configurations](#configuration-options)), which will try to connect to a web socket server on the same port and use redirectMap with most recent data.

This feature works best when accompanied with [bos-workspace](https://github.com/nearbuilders/bos-workspace), which will automatically inject a config to the attribute if you provide the path to your web component's dist, or a link to it stored on [NEARFS](https://github.com/vgrichina/nearfs). See more in [Customizing the Gateway](https://github.com/NEARBuilders/bos-workspace?tab=readme-ov-file#customizing-the-gateway). It can be disabled with the `--no-hot` flag.

## Configuring Ethers

Since [NearSocial/VM v1.3.0](https://github.com/NearSocial/VM/blob/master/CHANGELOG.md#130), the VM has exposed Ethers and ethers in the global scope, as well as a Web3Connect custom element for bringing up wallet connect.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
"react-bootstrap-typeahead": "^6.1.2",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",
"styled-components": "^5.3.6"
},
"scripts": {
Expand Down
158 changes: 149 additions & 9 deletions playwright-tests/tests/redirectmap.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { test, describe, expect } from "@playwright/test";
import { describe, expect, test } from "@playwright/test";
import http from "http";
import { Server } from "socket.io";
import { waitForSelectorToBeVisible } from "../testUtils";

describe("bos-loader-url", () => {
test.use({
Expand Down Expand Up @@ -42,14 +45,151 @@ describe("session-storage", () => {
})
);
});
await page.goto("/something.near/widget/testcomponent");
await page.evaluate(() => {
console.log(
JSON.parse(sessionStorage.getItem("nearSocialVMredirectMap"))
);
});

describe("hot-reload", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("should trigger api request to */socket.io/* if hot reload is enabled", async ({
page,
}) => {
let websocketCount = 0;

await page.route("**/socket.io/*", (route) => {
websocketCount++;
route.continue();
});

await page.evaluate(() => {
document.body.innerHTML = `<near-social-viewer src="neardevs.testnet/widget/default" config='{"dev": { "hotreload": { "enabled": true } } }'></near-social-viewer>`;
});

await waitForSelectorToBeVisible(page, "near-social-viewer");

expect(websocketCount).toBeGreaterThan(0);
});

test("should not trigger api request to */socket.io/* if hot reload is not enabled", async ({
page,
}) => {
let websocketCount = 0;

await page.route("**/socket.io/*", (route) => {
websocketCount++;
route.continue();
});

await page.evaluate(() => {
document.body.innerHTML = `<near-social-viewer src="neardevs.testnet/widget/default"></near-social-viewer>`;
});

await waitForSelectorToBeVisible(page, "near-social-viewer");

expect(websocketCount).toEqual(0);
});

describe("with running socket server", () => {
let io, httpServer;
const PORT = 3001;
let HOST = "localhost";

test.beforeAll(async () => {
httpServer = http.createServer();

io = new Server(httpServer, {
cors: {
origin: `http://${HOST}:3000`,
methods: ["GET", "POST"],
},
});

io.on("connection", () => {
io.emit("fileChange", {
"anybody.near/widget/test": {
code: "return <p>hello world</p>;",
},
});
});

// wait for socket start
await new Promise((resolve) => {
httpServer.listen(PORT, HOST, () => {
resolve();
});
});
});

test("should show local redirect map and react to changes", async ({
page,
}) => {
// Verify the viewer is visible
await waitForSelectorToBeVisible(page, "near-social-viewer");

await page.evaluate(() => {
const viewer = document.querySelector("near-social-viewer");
viewer.setAttribute("src", "anybody.near/widget/test"); // this code does not exist
});

await page.waitForSelector(
'div.alert.alert-danger:has-text("is not found")'
);

// Verify error
const errMsg = await page.locator(
'div.alert.alert-danger:has-text("is not found")'
);

expect(await errMsg.isVisible()).toBe(true);

let websocketCount = 0;

await page.route("**/socket.io/*", (route) => {
websocketCount++;
route.continue();
});

const config = {
dev: { hotreload: { enabled: true, wss: `ws://${HOST}:${PORT}` } },
};

// Enable hot reload
await page.evaluate(
({ config }) => {
const viewer = document.querySelector("near-social-viewer");
viewer.setAttribute("config", JSON.stringify(config));
},
{ config }
);

await page.waitForSelector("near-social-viewer");

// Get the value of the config attribute
const actualConfig = await page.evaluate(() => {
const viewer = document.querySelector("near-social-viewer");
return viewer.getAttribute("config");
});

// Assert it is set and equals custom value
expect(actualConfig).toBe(JSON.stringify(config));

// Assert web socket was hit
expect(websocketCount).toBeGreaterThan(0);

await expect(await page.getByText("hello world")).toBeVisible();

io.emit("fileChange", {
"anybody.near/widget/test": { code: "return <p>goodbye world</p>;" },
});

await expect(await page.getByText("goodbye world")).toBeVisible();
});

test.afterAll(() => {
io.close();
httpServer.close();
});
});
await expect(
await page.getByText("I come from a redirect map from session storage")
).toBeVisible();
});
});
4 changes: 1 addition & 3 deletions playwright-tests/tests/web3.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,5 @@ test("should be possible to interact with web3 widgets", async ({ page }) => {

await Web3ConnectButton.click();

await expect(
page.getByRole("button", { name: "Connecting" })
).toBeVisible();
await expect(page.getByRole("button", { name: "Connecting" })).toBeVisible();
});
40 changes: 15 additions & 25 deletions src/App.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "App.scss";
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap/dist/js/bootstrap.bundle";
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo } from "react";
import "react-bootstrap-typeahead/css/Typeahead.css";

import { isValidAttribute } from "dompurify";
Expand All @@ -13,10 +13,9 @@ import {
useLocation,
} from "react-router-dom";

import { BosWorkspaceProvider, useRedirectMap } from "./utils/bos-workspace";
import { EthersProvider } from "./utils/web3/ethers";

const SESSION_STORAGE_REDIRECT_MAP_KEY = "nearSocialVMredirectMap";

function Viewer({ widgetSrc, code, initialProps }) {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
Expand All @@ -36,23 +35,7 @@ function Viewer({ widgetSrc, code, initialProps }) {
return pathSrc;
}, [widgetSrc, path]);

const [redirectMap, setRedirectMap] = useState(null);
useEffect(() => {
(async () => {
const localStorageFlags = JSON.parse(localStorage.getItem("flags"));

if (localStorageFlags?.bosLoaderUrl) {
setRedirectMap(
(await fetch(localStorageFlags.bosLoaderUrl).then((r) => r.json()))
.components
);
} else {
setRedirectMap(
JSON.parse(sessionStorage.getItem(SESSION_STORAGE_REDIRECT_MAP_KEY))
);
}
})();
}, []);
const redirectMap = useRedirectMap();

return (
<>
Expand All @@ -67,12 +50,15 @@ function Viewer({ widgetSrc, code, initialProps }) {
}

function App(props) {
const { src, code, initialProps, rpc, network, selectorPromise } = props;
const { src, code, initialProps, rpc, network, selectorPromise, config } =
props;

const { initNear } = useInitNear();

useAccount();

useEffect(() => {
const config = {
const VM = {
networkId: network || "mainnet",
selector: selectorPromise,
customElements: {
Expand Down Expand Up @@ -100,10 +86,10 @@ function App(props) {
};

if (rpc) {
config.config.nodeUrl = rpc;
VM.config.nodeUrl = rpc;
}

initNear && initNear(config);
initNear && initNear(VM);
}, [initNear, rpc]);

const router = createBrowserRouter([
Expand All @@ -117,7 +103,11 @@ function App(props) {
},
]);

return <RouterProvider router={router} />;
return (
<BosWorkspaceProvider config={config?.dev}>
<RouterProvider router={router} />
</BosWorkspaceProvider>
);
}

export default App;
6 changes: 4 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import "./index.css";

class NearSocialViewerElement extends HTMLElement {
constructor() {
Expand All @@ -27,7 +27,7 @@ class NearSocialViewerElement extends HTMLElement {
}

static get observedAttributes() {
return ["src", "code", "initialprops", "rpc", "network"];
return ["src", "code", "initialprops", "rpc", "network", "config"];
}

renderRoot() {
Expand All @@ -36,6 +36,7 @@ class NearSocialViewerElement extends HTMLElement {
const initialProps = this.getAttribute("initialprops");
const rpc = this.getAttribute("rpc");
const network = this.getAttribute("network");
const config = this.getAttribute("config");

this.reactRoot.render(
<App
Expand All @@ -45,6 +46,7 @@ class NearSocialViewerElement extends HTMLElement {
rpc={rpc}
network={network}
selectorPromise={this.selectorPromise}
config={JSON.parse(config)}
/>
);
}
Expand Down
Loading

0 comments on commit a3958f1

Please sign in to comment.