diff --git a/.changeset/chilly-readers-heal.md b/.changeset/chilly-readers-heal.md new file mode 100644 index 0000000000..d5470a3ada --- /dev/null +++ b/.changeset/chilly-readers-heal.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +Refactored `observer` initialization to reuse bridge iframes with the same `url`. diff --git a/.changeset/mean-radios-search.md b/.changeset/mean-radios-search.md new file mode 100644 index 0000000000..146888fb1f --- /dev/null +++ b/.changeset/mean-radios-search.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/explorer": patch +--- + +Fixed favicon paths and fixed a few issues where we were incorrectly redirecting based on the chain name or ID. diff --git a/.changeset/unlucky-icons-wonder.md b/.changeset/unlucky-icons-wonder.md new file mode 100644 index 0000000000..653142c8e4 --- /dev/null +++ b/.changeset/unlucky-icons-wonder.md @@ -0,0 +1,10 @@ +--- +"@latticexyz/explorer": patch +--- + +Fixed an issue where the `observer` Viem client decorator required an empty object arg when no options are used. + +```diff +-client.extend(observer({})); ++client.extend(observer()); +``` diff --git a/packages/explorer/next.config.mjs b/packages/explorer/next.config.mjs index cffb1b98ba..7811d5e71d 100644 --- a/packages/explorer/next.config.mjs +++ b/packages/explorer/next.config.mjs @@ -10,10 +10,15 @@ export default function config() { }, redirects: async () => { return [ + { + source: "/worlds/:path*", + destination: "/anvil/worlds/:path*", + permanent: false, + }, { source: "/:chainName/worlds/:worldAddress/explorer", destination: "/:chainName/worlds/:worldAddress/explore", - permanent: true, + permanent: false, }, ]; }, diff --git a/packages/explorer/src/app/error.tsx b/packages/explorer/src/app/(explorer)/error.tsx similarity index 88% rename from packages/explorer/src/app/error.tsx rename to packages/explorer/src/app/(explorer)/error.tsx index 844d3e5723..c599bd6ab8 100644 --- a/packages/explorer/src/app/error.tsx +++ b/packages/explorer/src/app/(explorer)/error.tsx @@ -2,8 +2,8 @@ import { ExternalLink, RefreshCwIcon } from "lucide-react"; import Link from "next/link"; -import { Button } from "../components/ui/Button"; -import { useWorldUrl } from "../hooks/useWorldUrl"; +import { Button } from "../../components/ui/Button"; +import { useWorldUrl } from "../../hooks/useWorldUrl"; type Props = { error: Error & { digest?: string }; @@ -14,7 +14,7 @@ export default function Error({ reset, error }: Props) { const getUrl = useWorldUrl(); return (
-

400

+

500

Something went wrong :(

{error.message && ( diff --git a/packages/explorer/src/app/globals.css b/packages/explorer/src/app/(explorer)/globals.css similarity index 100% rename from packages/explorer/src/app/globals.css rename to packages/explorer/src/app/(explorer)/globals.css diff --git a/packages/explorer/public/favicon.svg b/packages/explorer/src/app/(explorer)/icon.svg similarity index 100% rename from packages/explorer/public/favicon.svg rename to packages/explorer/src/app/(explorer)/icon.svg diff --git a/packages/explorer/src/app/layout.tsx b/packages/explorer/src/app/(explorer)/layout.tsx similarity index 96% rename from packages/explorer/src/app/layout.tsx rename to packages/explorer/src/app/(explorer)/layout.tsx index 1dd8395953..533d78abcf 100644 --- a/packages/explorer/src/app/layout.tsx +++ b/packages/explorer/src/app/(explorer)/layout.tsx @@ -19,9 +19,6 @@ const jetbrains = JetBrains_Mono({ export const metadata: Metadata = { title: "World Explorer", description: "World Explorer is a tool for visually exploring and manipulating the state of worlds", - icons: { - icon: "/favicon.svg", - }, }; export default function RootLayout({ diff --git a/packages/explorer/src/app/not-found.tsx b/packages/explorer/src/app/(explorer)/not-found.tsx similarity index 90% rename from packages/explorer/src/app/not-found.tsx rename to packages/explorer/src/app/(explorer)/not-found.tsx index a4cddf5996..44059b412c 100644 --- a/packages/explorer/src/app/not-found.tsx +++ b/packages/explorer/src/app/(explorer)/not-found.tsx @@ -2,8 +2,8 @@ import { ExternalLink } from "lucide-react"; import Link from "next/link"; -import { Button } from "../components/ui/Button"; -import { useWorldUrl } from "../hooks/useWorldUrl"; +import { Button } from "../../components/ui/Button"; +import { useWorldUrl } from "../../hooks/useWorldUrl"; export default function NotFound() { const getUrl = useWorldUrl(); diff --git a/packages/explorer/src/app/page.tsx b/packages/explorer/src/app/(explorer)/page.tsx similarity index 67% rename from packages/explorer/src/app/page.tsx rename to packages/explorer/src/app/(explorer)/page.tsx index cfa56011be..99c90345a5 100644 --- a/packages/explorer/src/app/page.tsx +++ b/packages/explorer/src/app/(explorer)/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { supportedChainsById, validateChainId } from "../common"; +import { chainIdToName, validateChainId } from "../../common"; export const dynamic = "force-dynamic"; @@ -7,6 +7,6 @@ export default function IndexPage() { const chainId = Number(process.env.CHAIN_ID); validateChainId(chainId); - const chainName = supportedChainsById[chainId]; + const chainName = chainIdToName[chainId] ?? "anvil"; return redirect(`/${chainName}/worlds`); } diff --git a/packages/explorer/src/app/api/world/route.ts b/packages/explorer/src/app/api/world/route.ts index 650dee9bad..4e4aa12ce9 100644 --- a/packages/explorer/src/app/api/world/route.ts +++ b/packages/explorer/src/app/api/world/route.ts @@ -3,12 +3,12 @@ import { getBlockNumber, getLogs } from "viem/actions"; import { helloStoreEvent } from "@latticexyz/store"; import { helloWorldEvent } from "@latticexyz/world"; import { getWorldAbi } from "@latticexyz/world/internal"; -import { SupportedChainIds, supportedChainsById, validateChainId } from "../../../common"; +import { supportedChainId, supportedChains, validateChainId } from "../../../common"; export const dynamic = "force-dynamic"; -async function getClient(chainId: SupportedChainIds) { - const chain = supportedChainsById[chainId]; +async function getClient(chainId: supportedChainId) { + const chain = Object.values(supportedChains).find((c) => c.id === chainId); const client = createWalletClient({ chain, transport: http(), @@ -17,7 +17,7 @@ async function getClient(chainId: SupportedChainIds) { return client; } -async function getParameters(chainId: SupportedChainIds, worldAddress: Address) { +async function getParameters(chainId: supportedChainId, worldAddress: Address) { const client = await getClient(chainId); const toBlock = await getBlockNumber(client); const logs = await getLogs(client, { diff --git a/packages/explorer/src/common.ts b/packages/explorer/src/common.ts index de77ede488..f139856721 100644 --- a/packages/explorer/src/common.ts +++ b/packages/explorer/src/common.ts @@ -1,21 +1,23 @@ import { anvil, garnet, redstone } from "viem/chains"; export const supportedChains = { anvil, garnet, redstone } as const; -export const supportedChainsById = Object.fromEntries( - Object.entries(supportedChains).map(([, chain]) => [chain.id, chain]), -); +export type supportedChains = typeof supportedChains; + +export type supportedChainName = keyof supportedChains; +export type supportedChainId = supportedChains[supportedChainName]["id"]; -export type SupportedChainIds = (typeof supportedChains)[keyof typeof supportedChains]["id"]; -export type SupportedChainNames = keyof typeof supportedChains; +export const chainIdToName = Object.fromEntries( + Object.entries(supportedChains).map(([chainName, chain]) => [chain.id, chainName]), +); -export function validateChainId(chainId: number): asserts chainId is SupportedChainIds { - if (!(chainId in supportedChainsById)) { - throw new Error(`Invalid chain id. Supported chains are: ${Object.keys(supportedChainsById).join(", ")}.`); +export function validateChainId(chainId: unknown): asserts chainId is supportedChainId { + if (!(typeof chainId === "number" && chainId in chainIdToName)) { + throw new Error(`Invalid chain ID. Supported chains are: ${Object.keys(chainIdToName).join(", ")}.`); } } -export function validateChainName(name: string | string[] | undefined): asserts name is SupportedChainNames { - if (Array.isArray(name) || typeof name !== "string" || !(name in supportedChains)) { - throw new Error(`Invalid chain name. Supported chains are: ${Object.keys(supportedChainsById).join(", ")}.`); +export function validateChainName(name: unknown): asserts name is supportedChainName { + if (!(typeof name === "string" && name in supportedChains)) { + throw new Error(`Invalid chain name. Supported chains are: ${Object.keys(supportedChains).join(", ")}.`); } } diff --git a/packages/explorer/src/observer/bridge.ts b/packages/explorer/src/observer/bridge.ts index 7be2ebb926..d29cfa4956 100644 --- a/packages/explorer/src/observer/bridge.ts +++ b/packages/explorer/src/observer/bridge.ts @@ -26,56 +26,61 @@ export type CreateBridgeOpts = { }; export function createBridge({ url, timeout = 10_000 }: CreateBridgeOpts): EmitMessage { - const emit = Promise.withResolvers(); - const iframe = document.createElement("iframe"); - iframe.tabIndex = -1; - iframe.ariaHidden = "true"; - iframe.style.position = "absolute"; - iframe.style.border = "0"; - iframe.style.width = "0"; - iframe.style.height = "0"; + const bridge = new Promise((resolve, reject) => { + const iframe = + Array.from(document.querySelectorAll("iframe[data-bridge][src]")) + .filter((el): el is HTMLIFrameElement => true) + .find((el) => el.src === url) ?? document.createElement("iframe"); - iframe.addEventListener( - "load", - () => { - debug("observer iframe ready", iframe.src); - // TODO: throw if `iframe.contentWindow` is `null`? - emit.resolve((type, data) => { - const message = wrapMessage({ ...data, type, time: Date.now() }); - debug("posting message to bridge", message); - iframe.contentWindow!.postMessage(message, "*"); - }); - }, - { once: true }, - ); - - iframe.addEventListener( - "error", - (error) => { - debug("observer iframe error", error); - emit.reject(error); - }, - { once: true }, - ); + if (iframe.dataset.bridge === "ready") { + debug("reusing observer iframe", iframe.src); + return resolve(iframe); + } - // TODO: should we let the caller handle this with their own promise timeout or race? - wait(timeout).then(() => { - emit.reject(new Error("Timed out waiting for observer iframe to load.")); - }); - - debug("mounting observer iframe", url); - iframe.src = url; - parent.document.body.appendChild(iframe); + Promise.race([ + new Promise((resolve, reject) => { + iframe.addEventListener("load", () => resolve(), { once: true }); + iframe.addEventListener("error", (error) => reject(error), { once: true }); + }), + wait(timeout).then(() => { + throw new Error("Timed out waiting for observer iframe to load."); + }), + ]).then( + () => { + debug("observer iframe ready", iframe.src); + iframe.dataset.bridge = "ready"; + resolve(iframe); + }, + (error) => { + debug("observer iframe error", error); + iframe.remove(); + reject(error); + }, + ); - emit.promise.catch(() => { - iframe.remove(); + if (iframe.dataset.bridge !== "loading") { + iframe.tabIndex = -1; + iframe.ariaHidden = "true"; + iframe.style.position = "absolute"; + iframe.style.border = "0"; + iframe.style.width = "0"; + iframe.style.height = "0"; + iframe.dataset.bridge = "loading"; + iframe.src = url; + debug("mounting observer iframe", url); + parent.document.body.appendChild(iframe); + } }); - return (messageType, message) => { - debug("got message for bridge", messageType, message); - emit.promise.then( - (fn) => fn(messageType, message), - (error) => debug("could not deliver message", message, error), + return (type, data) => { + debug("got message for bridge", type, data); + bridge.then( + (iframe) => { + debug("posting message to bridge", type, data); + const message = wrapMessage({ type, time: Date.now(), ...data }); + iframe.contentWindow!.postMessage(message, "*"); + }, + (error) => debug("could not deliver message", type, data, error), ); }; } diff --git a/packages/explorer/src/observer/decorator.ts b/packages/explorer/src/observer/decorator.ts index cee3a22c2b..f23c15c203 100644 --- a/packages/explorer/src/observer/decorator.ts +++ b/packages/explorer/src/observer/decorator.ts @@ -11,62 +11,57 @@ export type ObserverOptions = { waitForStateChange?: WaitForStateChange; }; +let writeCounter = 0; + export function observer({ explorerUrl = "http://localhost:13690", waitForStateChange, -}: ObserverOptions): ( +}: ObserverOptions = {}): ( client: Client, ) => Pick, "writeContract"> { const emit = createBridge({ url: `${explorerUrl}/internal/observer-relay` }); - setInterval(() => { - emit("ping", {}); - }, 2000); + return (client) => ({ + async writeContract(args) { + const writeId = `${client.uid}-${++writeCounter}`; + const write = getAction(client, writeContract, "writeContract")(args); - return (client) => { - let counter = 0; - return { - async writeContract(args) { - const writeId = `${client.uid}-${++counter}`; - const write = getAction(client, writeContract, "writeContract")(args); + // `writeContract` above will throw if this isn't present + const functionAbiItem = getAbiItem({ + abi: args.abi, + name: args.functionName, + args: args.args, + } as never)!; - // `writeContract` above will throw if this isn't present - const functionAbiItem = getAbiItem({ - abi: args.abi, - name: args.functionName, - args: args.args, - } as never)!; + emit("write", { + writeId, + address: args.address, + functionSignature: formatAbiItem(functionAbiItem), + args: (args.args ?? []) as never, + }); + Promise.allSettled([write]).then(([result]) => { + emit("write:result", { ...result, writeId }); + }); - emit("write", { - writeId, - address: args.address, - functionSignature: formatAbiItem(functionAbiItem), - args: (args.args ?? []) as never, - }); - Promise.allSettled([write]).then(([result]) => { - emit("write:result", { ...result, writeId }); + write.then((hash) => { + const receipt = getAction(client, waitForTransactionReceipt, "waitForTransactionReceipt")({ hash }); + emit("waitForTransactionReceipt", { writeId }); + Promise.allSettled([receipt]).then(([result]) => { + emit("waitForTransactionReceipt:result", { ...result, writeId }); }); + }); + if (waitForStateChange) { write.then((hash) => { - const receipt = getAction(client, waitForTransactionReceipt, "waitForTransactionReceipt")({ hash }); - emit("waitForTransactionReceipt", { writeId }); + const receipt = waitForStateChange(hash); + emit("waitForStateChange", { writeId }); Promise.allSettled([receipt]).then(([result]) => { - emit("waitForTransactionReceipt:result", { ...result, writeId }); + emit("waitForStateChange:result", { ...result, writeId }); }); }); + } - if (waitForStateChange) { - write.then((hash) => { - const receipt = waitForStateChange(hash); - emit("waitForStateChange", { writeId }); - Promise.allSettled([receipt]).then(([result]) => { - emit("waitForStateChange:result", { ...result, writeId }); - }); - }); - } - - return write; - }, - }; - }; + return write; + }, + }); }