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 12 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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ The `near-social-viewer` web component supports several attributes:
* `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
* `enablehotreload`: option to connect to local web socket server for live redirect map changes

## Configuring VM Custom Elements

Expand Down Expand Up @@ -119,7 +120,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 @@ -135,6 +146,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 to `enablehotreload`, 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 it to the `enablehotreload` 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.

## Landing page for SEO friendly URLs

Normally, the URL path decides which component to be loaded. The path `/devhub.near/widget/app` will load the `app` component from the `devhub.near` account. DevHub is an example of a collection of many components that are part of a big app, and the `app` component is just a proxy to components that represent a `page`. Which page to display is controlled by the `page` query string parameter, which translates to `props.page` in the component.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"react-bootstrap-typeahead": "^6.1.2",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"socket.io-client": "^4.7.5",
"styled-components": "^5.3.6"
},
"scripts": {
Expand Down
47 changes: 46 additions & 1 deletion playwright-tests/tests/redirectmap.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, describe, expect } from '@playwright/test';
import { describe, expect, test } from "@playwright/test";
import { waitForSelectorToBeVisible } from "../testUtils";

describe("bos-loader-url", () => {
test.use({
Expand Down Expand Up @@ -44,4 +45,48 @@ describe("session-storage", () => {
})
await expect(await page.getByText('I come from a redirect map from session storage')).toBeVisible();
});
});

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

await page.goto("/");

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

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

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

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

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

await page.goto("/");

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);
});
});
39 changes: 18 additions & 21 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "App.scss";
import "bootstrap-icons/font/bootstrap-icons.css";
import "bootstrap/dist/js/bootstrap.bundle";
import { Widget } from "near-social-vm";
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo } from "react";
import "react-bootstrap-typeahead/css/Typeahead.css";

import { sanitizeUrl } from "@braintree/sanitize-url";
Expand All @@ -14,7 +14,7 @@ import {
useLocation,
} from "react-router-dom";

const SESSION_STORAGE_REDIRECT_MAP_KEY = 'nearSocialVMredirectMap';
import { useRedirectMap, RedirectMapProvider } from "./utils/redirectMap";

function Viewer({ widgetSrc, code, initialProps }) {
const location = useLocation();
Expand All @@ -35,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 @@ -66,7 +50,16 @@ function Viewer({ widgetSrc, code, initialProps }) {
}

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

const { initNear } = useInitNear();

useAccount();
Expand Down Expand Up @@ -110,7 +103,11 @@ function App(props) {
},
]);

return <RouterProvider router={router} />;
return (
<RedirectMapProvider enableHotReload={enableHotReload}>
<RouterProvider router={router} />
</RedirectMapProvider>
);
}

export default App;
7 changes: 4 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class NearSocialViewerElement extends HTMLElement {
}

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

renderRoot() {
Expand All @@ -34,8 +34,9 @@ class NearSocialViewerElement extends HTMLElement {
const initialProps = this.getAttribute('initialprops');
const rpc = this.getAttribute('rpc');
const network = this.getAttribute('network');
const enableHotReload = this.hasAttribute("enablehotreload");

this.reactRoot.render(<App src={src} code={code} initialProps={JSON.parse(initialProps)} rpc={rpc} network={network} selectorPromise={this.selectorPromise} />);
this.reactRoot.render(<App src={src} code={code} initialProps={JSON.parse(initialProps)} rpc={rpc} network={network} selectorPromise={this.selectorPromise} enableHotReload={enableHotReload}/>);
}

attributeChangedCallback(name, oldValue, newValue) {
Expand All @@ -45,4 +46,4 @@ class NearSocialViewerElement extends HTMLElement {
}
}

customElements.define('near-social-viewer', NearSocialViewerElement);
customElements.define('near-social-viewer', NearSocialViewerElement);
66 changes: 66 additions & 0 deletions src/utils/redirectMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import io from "socket.io-client";

const SESSION_STORAGE_REDIRECT_MAP_KEY = "nearSocialVMredirectMap";

const HotReloadContext = createContext(false);

export const RedirectMapProvider = ({ enableHotReload, children }) => {
return (
<HotReloadContext.Provider value={enableHotReload}>
{children}
</HotReloadContext.Provider>
);
};

export function useRedirectMap() {
const enableHotReload = useContext(HotReloadContext);

const [hotReload, setHotReload] = useState(enableHotReload);
const [devJson, setDevJson] = useState({
bb-face marked this conversation as resolved.
Show resolved Hide resolved
components: {},
data: {},
});

useEffect(() => {
(async () => {
if (hotReload) {
const socket = io(`ws://${window.location.host}`, {
reconnectionAttempts: 1, // Limit reconnection attempts
});

socket.on("fileChange", (d) => {
console.log("File change detected via WebSocket", d);
setDevJson(d.components);
});

socket.on("connect_error", (error) => {
console.warn("WebSocket connection error. Switching to HTTP.");
console.warn(error)

setHotReload(false);
socket.disconnect();
});

return () => {
socket.disconnect();
};
} else {
const localStorageFlags = JSON.parse(localStorage.getItem("flags"));

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

return devJson;
}
56 changes: 56 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4173,6 +4173,11 @@
"@smithy/types" "^2.12.0"
tslib "^2.6.2"

"@socket.io/component-emitter@~3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==

"@swc/helpers@^0.4.14":
version "0.4.14"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74"
Expand Down Expand Up @@ -5988,6 +5993,13 @@ debug@^3.2.7:
dependencies:
ms "^2.1.1"

debug@~4.3.1, debug@~4.3.2:
version "4.3.5"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e"
integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==
dependencies:
ms "2.1.2"

decamelize-keys@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8"
Expand Down Expand Up @@ -6319,6 +6331,22 @@ encoding@^0.1.12, encoding@^0.1.13:
dependencies:
iconv-lite "^0.6.2"

engine.io-client@~6.5.2:
version "6.5.4"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.4.tgz#b8bc71ed3f25d0d51d587729262486b4b33bd0d0"
integrity sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.17.1"
xmlhttprequest-ssl "~2.0.0"

engine.io-parser@~5.2.1:
version "5.2.2"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49"
integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==

enhanced-resolve@^5.10.0:
version "5.12.0"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634"
Expand Down Expand Up @@ -11207,6 +11235,24 @@ snake-case@^3.0.4:
dot-case "^3.0.4"
tslib "^2.0.3"

socket.io-client@^4.7.5:
version "4.7.5"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.5.tgz#919be76916989758bdc20eec63f7ee0ae45c05b7"
integrity sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.2"
engine.io-client "~6.5.2"
socket.io-parser "~4.2.4"

socket.io-parser@~4.2.4:
version "4.2.4"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83"
integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"

sockjs@^0.3.24:
version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
Expand Down Expand Up @@ -12383,6 +12429,11 @@ ws@^8.5.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f"
integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==

ws@~8.17.1:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==

xml2js@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"
Expand All @@ -12396,6 +12447,11 @@ xmlbuilder@~11.0.0:
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==

xmlhttprequest-ssl@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==

xtend@^4.0.0, xtend@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
Expand Down
Loading