Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hot reload #26

Merged
merged 23 commits into from
Jul 10, 2024
Merged
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
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
Loading