Skip to content

Commit

Permalink
feat(explorer): write observer (#3169)
Browse files Browse the repository at this point in the history
Co-authored-by: Karolis Ramanauskas <[email protected]>
  • Loading branch information
holic and karooolis authored Sep 12, 2024
1 parent d3acd92 commit 784e5a9
Show file tree
Hide file tree
Showing 39 changed files with 590 additions and 84 deletions.
33 changes: 33 additions & 0 deletions .changeset/smart-parents-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@latticexyz/explorer": patch
---

World Explorer package now exports an `observer` Viem decorator that can be used to get visibility into contract writes initiated from your app. You can watch these writes stream in on the new "Observe" tab of the World Explorer.

```ts
import { createClient, publicActions, walletActions } from "viem";
import { observer } from "@latticexyz/explorer/observer";

const client = createClient({ ... })
.extend(publicActions)
.extend(walletActions)
.extend(observer());
```

By default, the `observer` action assumes the World Explorer is running at `http://localhost:13690`, but this can be customized with the `explorerUrl` option.

```ts
observer({
explorerUrl: "http://localhost:4444",
});
```

If you want to measure the timing of transaction-to-state-change, you can also pass in a `waitForStateChange` function that takes a transaction hash and returns a partial [`TransactionReceipt`](https://viem.sh/docs/glossary/types#transactionreceipt) with `blockNumber`, `status`, and `transactionHash`. This mirrors the `waitForTransaction` function signature returned by `syncTo...` helper in `@latticexyz/store-sync`.

```ts
observer({
async waitForStateChange(hash) {
return await waitForTransaction(hash);
},
});
```
2 changes: 1 addition & 1 deletion examples/local-explorer/packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"dependencies": {
"@latticexyz/common": "link:../../../../packages/common",
"@latticexyz/dev-tools": "link:../../../../packages/dev-tools",
"@latticexyz/explorer": "link:../../../../packages/explorer",
"@latticexyz/react": "link:../../../../packages/react",
"@latticexyz/schema-type": "link:../../../../packages/schema-type",
"@latticexyz/store-sync": "link:../../../../packages/store-sync",
Expand Down
17 changes: 0 additions & 17 deletions examples/local-explorer/packages/client/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import ReactDOM from "react-dom/client";
import { App } from "./App";
import { setup } from "./mud/setup";
import { MUDProvider } from "./MUDContext";
import mudConfig from "contracts/mud.config";

const rootElement = document.getElementById("react-root");
if (!rootElement) throw new Error("React root not found");
Expand All @@ -15,20 +14,4 @@ setup().then(async (result) => {
<App />
</MUDProvider>,
);

// https://vitejs.dev/guide/env-and-mode.html
if (import.meta.env.DEV) {
const { mount: mountDevTools } = await import("@latticexyz/dev-tools");
mountDevTools({
config: mudConfig,
publicClient: result.network.publicClient,
walletClient: result.network.walletClient,
latestBlock$: result.network.latestBlock$,
storedBlockLogs$: result.network.storedBlockLogs$,
worldAddress: result.network.worldContract.address,
worldAbi: result.network.worldContract.abi,
write$: result.network.write$,
useStore: result.network.useStore,
});
}
});
21 changes: 10 additions & 11 deletions examples/local-explorer/packages/client/src/mud/setupNetwork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
import { syncToZustand } from "@latticexyz/store-sync/zustand";
import { getNetworkConfig } from "./getNetworkConfig";
import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json";
import { createBurnerAccount, transportObserver, ContractWrite } from "@latticexyz/common";
import { transactionQueue, writeObserver } from "@latticexyz/common/actions";
import { Subject, share } from "rxjs";
import { createBurnerAccount, transportObserver } from "@latticexyz/common";
import { transactionQueue } from "@latticexyz/common/actions";
import { observer, type WaitForStateChange } from "@latticexyz/explorer/observer";

/*
* Import our MUD config, which includes strong types for
Expand All @@ -34,6 +34,7 @@ export type SetupNetworkResult = Awaited<ReturnType<typeof setupNetwork>>;

export async function setupNetwork() {
const networkConfig = await getNetworkConfig();
const waitForStateChange = Promise.withResolvers<WaitForStateChange>();

/*
* Create a viem public (read only) client
Expand All @@ -47,12 +48,6 @@ export async function setupNetwork() {

const publicClient = createPublicClient(clientOptions);

/*
* Create an observable for contract writes that we can
* pass into MUD dev tools for transaction observability.
*/
const write$ = new Subject<ContractWrite>();

/*
* Create a temporary wallet and a viem client for it
* (see https://viem.sh/docs/clients/wallet.html).
Expand All @@ -63,7 +58,11 @@ export async function setupNetwork() {
account: burnerAccount,
})
.extend(transactionQueue())
.extend(writeObserver({ onWrite: (write) => write$.next(write) }));
.extend(
observer({
waitForStateChange: (hash) => waitForStateChange.promise.then((fn) => fn(hash)),
}),
);

/*
* Create an object for communicating with the deployed World.
Expand All @@ -86,6 +85,7 @@ export async function setupNetwork() {
publicClient,
startBlock: BigInt(networkConfig.initialBlockNumber),
});
waitForStateChange.resolve(waitForTransaction);

return {
tables,
Expand All @@ -96,6 +96,5 @@ export async function setupNetwork() {
storedBlockLogs$,
waitForTransaction,
worldContract,
write$: write$.asObservable().pipe(share()),
};
}
6 changes: 3 additions & 3 deletions examples/local-explorer/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "turbo run build",
"changelog:generate": "tsx scripts/changelog.ts",
"clean": "turbo run clean",
"dev": "TSUP_SKIP_DTS=true turbo run dev --concurrency 100 --filter=!@latticexyz/explorer",
"dev": "TSUP_SKIP_DTS=true turbo run dev --concurrency 100",
"dist-tag-rm": "pnpm recursive exec -- sh -c 'npm dist-tag rm $(cat package.json | jq -r \".name\") $TAG || true'",
"docs:generate:api": "tsx scripts/render-api-docs.ts",
"foundryup": "curl -L https://foundry.paradigm.xyz | bash && bash ~/.foundry/bin/foundryup",
Expand Down
3 changes: 3 additions & 0 deletions packages/explorer/bin/explorer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node
// workaround for https://github.com/pnpm/pnpm/issues/1801
import "../dist/bin/explorer.js";
30 changes: 22 additions & 8 deletions packages/explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,35 @@
"version": "2.2.3",
"description": "World Explorer is a tool for visually exploring and manipulating the state of worlds",
"type": "module",
"exports": {
"./observer": "./dist/exports/observer.js"
},
"typesVersions": {
"*": {
"observer": [
"./dist/exports/observer.d.ts"
]
}
},
"bin": {
"explorer": "./dist/explorer.js"
"explorer": "./bin/explorer.js"
},
"files": [
"bin",
"dist",
".next/standalone/packages/explorer"
],
"scripts": {
"build": "pnpm run build:explorer && pnpm run build:bin",
"build:bin": "tsup",
"build": "pnpm run build:js && pnpm run build:explorer",
"build:explorer": "next build && shx cp -r .next/static .next/standalone/packages/explorer/.next",
"clean": "pnpm run clean:explorer && pnpm run clean:bin",
"clean:bin": "shx rm -rf dist",
"build:js": "tsup",
"clean": "pnpm run clean:js && pnpm run clean:explorer",
"clean:explorer": "shx rm -rf .next .turbo",
"dev": "next dev --port 13690",
"lint": "next lint",
"start": "node .next/standalone/packages/explorer/server.js"
"clean:js": "shx rm -rf dist",
"dev": "tsup --watch",
"explorer:dev": "next dev --port 13690",
"explorer:start": "node .next/standalone/packages/explorer/server.js",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
Expand All @@ -44,6 +56,7 @@
"better-sqlite3": "^8.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"debug": "^4.3.4",
"lucide-react": "^0.408.0",
"next": "14.2.5",
"query-string": "^9.1.0",
Expand All @@ -62,6 +75,7 @@
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/better-sqlite3": "^7.6.4",
"@types/debug": "^4.1.7",
"@types/minimist": "^1.2.5",
"@types/node": "^18.15.11",
"@types/react": "18.2.22",
Expand Down
2 changes: 1 addition & 1 deletion packages/explorer/src/app/(explorer)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function Error({ reset, error }: Props) {

<div className="mt-10 flex items-center justify-center gap-x-6">
<Button asChild>
<Link href={getUrl("explorer")}>Go back to explorer</Link>
<Link href={getUrl("explore")}>Go back to explorer</Link>
</Button>

<Button variant="secondary" onClick={reset}>
Expand Down
2 changes: 1 addition & 1 deletion packages/explorer/src/app/(explorer)/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function NotFound() {
<p className="mt-6 text-base leading-7 text-white/70">Sorry, we couldn’t find the page you’re looking for.</p>
<div className="mt-10 flex items-center justify-center gap-x-6">
<Button asChild>
<Link href={getUrl("explorer")}>Go back to explorer</Link>
<Link href={getUrl("explore")}>Go back to explorer</Link>
</Button>

<Button variant="secondary" asChild>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { type Write } from "../../../../../observer/store";
import { msPerViewportWidth } from "./common";

export type Props = Write;

export function Write({ functionSignature, time: start, events }: Props) {
return (
<div className="pr-[10vw]">
<div className="font-bold opacity-40 group-hover/write:opacity-100">
{functionSignature} <span className="opacity-50">{new Date(start).toLocaleTimeString()}</span>
</div>
<div className="inline-grid bg-cyan-400/10 text-cyan-400">
{events.map((event) => (
<div key={event.type} className="col-start-1 flex">
<div
className="pointer-events-none shrink-0"
style={{ width: `${((event.time - start) / msPerViewportWidth) * 100}vw` }}
/>
<div className="h-[1.25em] shrink-0 whitespace-nowrap border-l-2 border-current">
<div className="pointer-events-none invisible absolute px-1 leading-[1.25em] opacity-70 group-hover/write:visible">
{event.type} <span className="text-white/60">{event.time - start}ms</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { useStore } from "zustand";
import { KeepInView } from "../../../../../components/KeepInView";
import { store } from "../../../../../observer/store";
import { Write } from "./Write";

export function Writes() {
const writes = useStore(store, (state) => Object.values(state.writes));

return (
// TODO: replace with h-full once container is stretched to full height
<div className="relative h-[80vh] overflow-auto">
<KeepInView>
<div className="flex flex-col gap-4 pb-10 text-xs leading-tight">
{writes.length === 0 ? <>Waiting for transactions…</> : null}
{writes.map((write) => (
<div key={write.writeId} className="group/write flex gap-2">
<Write {...write} />
</div>
))}
</div>
</KeepInView>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const msPerViewportWidth = 1000 * 60 * 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Writes } from "./Writes";

export default function ObservePage() {
return <Writes />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";

export default async function WorldPage({ params }: { params: { worldAddress: string } }) {
return redirect(`/worlds/${params.worldAddress}/explorer`);
return redirect(`/worlds/${params.worldAddress}/explore`);
}
7 changes: 7 additions & 0 deletions packages/explorer/src/app/internal/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
9 changes: 9 additions & 0 deletions packages/explorer/src/app/internal/observer-relay/Relay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

import { useEffect } from "react";
import { createRelay } from "../../../observer/relay";

export function Relay() {
useEffect(createRelay, []);
return null;
}
5 changes: 5 additions & 0 deletions packages/explorer/src/app/internal/observer-relay/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Relay } from "./Relay";

export default function ObserverRelayPage() {
return <Relay />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ChildProcess, spawn } from "child_process";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.join(__dirname, "..", "..");

const argv = yargs(process.argv.slice(2))
.options({
Expand Down Expand Up @@ -72,14 +73,14 @@ async function startExplorer() {
"node_modules/.bin/next",
["dev", "--port", port.toString(), ...(hostname ? ["--hostname", hostname] : [])],
{
cwd: path.join(__dirname, ".."),
cwd: packageRoot,
stdio: "inherit",
env,
},
);
} else {
explorerProcess = spawn("node", [".next/standalone/packages/explorer/server.js"], {
cwd: path.join(__dirname, ".."),
cwd: packageRoot,
stdio: "inherit",
env: {
...env,
Expand Down
38 changes: 38 additions & 0 deletions packages/explorer/src/components/KeepInView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ReactNode, useRef } from "react";

export type Props = {
className?: string;
children: ReactNode;
enabled?: boolean;
};

export function KeepInView({ className, children, enabled = true }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const hoveredRef = useRef(false);
const scrollBehaviorRef = useRef<ScrollBehavior>("auto");

// Intentionally not in a `useEffect` so this triggers on every render.
if (!hoveredRef.current && enabled) {
containerRef.current?.scrollIntoView({
behavior: scrollBehaviorRef.current,
block: "end",
inline: "end",
});
}
scrollBehaviorRef.current = "smooth";

return (
<div
ref={containerRef}
onMouseEnter={() => {
hoveredRef.current = true;
}}
onMouseLeave={() => {
hoveredRef.current = false;
}}
className={className}
>
{children}
</div>
);
}
Loading

0 comments on commit 784e5a9

Please sign in to comment.