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

feat(explorer): write observer #3169

Merged
merged 6 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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
Copy link
Contributor

Choose a reason for hiding this comment

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

Now that the path has changed, some links don't work anymore. Noticed in error.tsx, not-found.tsx, and worlds/[worldAddress]/page.tsx.

Copy link
Member Author

Choose a reason for hiding this comment

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

good catch, fixed!

File renamed without changes.
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 />;
}
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
Loading