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

fix(explorer): various fixes #3195

Merged
merged 11 commits into from
Sep 19, 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
5 changes: 5 additions & 0 deletions .changeset/chilly-readers-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@latticexyz/explorer": patch
---

Refactored `observer` initialization to reuse bridge iframes with the same `url`.
5 changes: 5 additions & 0 deletions .changeset/mean-radios-search.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/unlucky-icons-wonder.md
Original file line number Diff line number Diff line change
@@ -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());
```
7 changes: 6 additions & 1 deletion packages/explorer/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -14,7 +14,7 @@ export default function Error({ reset, error }: Props) {
const getUrl = useWorldUrl();
return (
<main className="px-6 py-24 text-center">
<p className="text-3xl font-semibold text-orange-600">400</p>
<p className="text-3xl font-semibold text-orange-600">500</p>
<h1 className="mt-4 text-3xl font-bold tracking-tight text-white sm:text-5xl">Something went wrong :(</h1>

{error.message && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { redirect } from "next/navigation";
import { supportedChainsById, validateChainId } from "../common";
import { chainIdToName, validateChainId } from "../../common";

export const dynamic = "force-dynamic";

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`);
}
8 changes: 4 additions & 4 deletions packages/explorer/src/app/api/world/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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, {
Expand Down
24 changes: 13 additions & 11 deletions packages/explorer/src/common.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

@karooolis karooolis Sep 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if it should start uppercase given it's a type, and that's been the convention so far? Same for supportedChainName and supportedChainId

Copy link
Member Author

@holic holic Sep 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just a shortcut to avoid having to sprinkle typeof everywhere you're using it with a const

when the name mirrors the type, you can use the same name/reference in both types and runtime (we borrowed this pattern from arktype and use it throughout config etc.)


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(", ")}.`);
}
}
95 changes: 50 additions & 45 deletions packages/explorer/src/observer/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,56 +26,61 @@ export type CreateBridgeOpts = {
};

export function createBridge({ url, timeout = 10_000 }: CreateBridgeOpts): EmitMessage {
const emit = Promise.withResolvers<EmitMessage>();
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<HTMLIFrameElement>((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<void>((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),
);
};
}
77 changes: 36 additions & 41 deletions packages/explorer/src/observer/decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,62 +11,57 @@ export type ObserverOptions = {
waitForStateChange?: WaitForStateChange;
};

let writeCounter = 0;

export function observer<transport extends Transport, chain extends Chain, account extends Account>({
explorerUrl = "http://localhost:13690",
waitForStateChange,
}: ObserverOptions): (
}: ObserverOptions = {}): (
client: Client<transport, chain, account>,
) => Pick<WalletActions<chain, account>, "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;
},
});
}
Loading