Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
# @suaveplan/react-network-libp2p-demo - React-tier demo UI for @suaveplan/network-transport-libp2p. Decomposed into per-concern subcomponents (status, controls, peer interaction, peer lists, log panel) each renderable standalone for Storybook isolation. Bootstrap list is a required React prop — no hardcoded public defaults.
## Summary
React-tier demo UI for `@suaveplan/network-transport-libp2p`. Splits the
upstream 749-LOC monolithic demo into per-concern subcomponents
(`<NodeStatusPanel>`, `<NodeControls>`, `<PeerInteractionPanel>`,
`<PeerList>`, `<LogPanel>`) wired together by a root `<Libp2pDemo>`
composer. Every subcomponent is standalone-renderable with mock data for
Storybook isolation; the lifecycle + state machine lives in the
`useLibp2pDemo` hook.
<!-- begin:auto-docs -->
## Documentation tree
- [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - wire-peer-events — Side-effect helper that subscrib…
- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./src/hooks/use-libp2p-demo/wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
<!-- end:auto-docs --> - [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./src/hooks/use-libp2p-demo/wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./src/hooks/use-libp2p-demo/wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./src/hooks/use-libp2p-demo/wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- [libp2p-demo](./src/components/libp2p-demo/libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-panel](./src/components/log-panel/log-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-controls](./src/components/node-controls/node-controls.md) — <!-- begin:auto-docs --> _(no submodules)_- [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [node-status-panel](./src/components/node-status-panel/node-status-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-interaction-panel](./src/components/peer-interaction-panel/peer-interaction-panel.md) — <!-- begin:auto-docs --> _(no submodules)_- [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_ - [peer-list](./src/components/peer-list/peer-list.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [use-libp2p-demo](./src/hooks/use-libp2p-demo/use-libp2p-demo.md) — <!-- begin:auto-docs --> _(no submodules)_ - [wire-peer-events](./src/hooks/use-libp2p-demo/wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
## Required user-supplied bootstrap
The package **never** publishes a public bootstrap list. The `<Libp2pDemo>`
composer (and the underlying `useLibp2pDemo` hook) accepts a `bootstrap:
readonly string[]` React prop that is REQUIRED — the array may be empty
(for mDNS-only or WebRTC-only flows) but the consumer must always supply
it explicitly. There are no hardcoded fallback peers anywhere in the
source tree.
```tsx
import { Libp2pDemo } from "@suaveplan/react-network-libp2p-demo";
<Libp2pDemo
nodeFactory={myLibp2pFactory}
bootstrap={["/dnsaddr/your-bootstrap.example/p2p/Qm…"]}
subTransport="webrtc"
/>;
```
This is a deliberate API choice: hardcoding a default would bake a
trust decision into the library that every downstream consumer would
silently inherit. Consumers stay in charge of which peers their demo
trusts.
## What this package owns
- **Root composer** — `<Libp2pDemo>` ([`components/libp2p-demo`](./src/components/libp2p-demo/libp2p-demo.md))
- **Hook** — `useLibp2pDemo` ([`hooks/use-libp2p-demo`](./src/hooks/use-libp2p-demo/use-libp2p-demo.md))
- **Peer-event wiring helper** — `wirePeerEvents` ([wiring](./src/hooks/use-libp2p-demo/wire-peer-events.md))
- **Status panel** — `<NodeStatusPanel>` ([panel](./src/components/node-status-panel/node-status-panel.md))
- **Lifecycle buttons** — `<NodeControls>` ([controls](./src/components/node-controls/node-controls.md))
- **Interaction row** — `<PeerInteractionPanel>` ([row](./src/components/peer-interaction-panel/peer-interaction-panel.md))
- **Peer lists** — `<PeerList>` ([list](./src/components/peer-list/peer-list.md))
- **Log viewer** — `<LogPanel>` ([logs](./src/components/log-panel/log-panel.md))
## Architecture
The package depends on `@suaveplan/network-transport-libp2p` as a peer
dependency, but never imports `libp2p` directly. Production consumers
implement a thin `Libp2pDemoNodeFactory` adapter that wraps the real
libp2p stack and conforms to the structural `Libp2pDemoNode` contract
defined here. Tests pass a deterministic fake conforming to the same
contract (`tests/setup/fake-node.ts`).
## Error registry
`defineCodes("REACT_LIBP2P_DEMO", […])` — 5 codes:
`INVALID_OPTIONS`, `DEMO_DISPOSED`, `NODE_NOT_STARTED`, `DIAL_FAILED`,
`ECHO_FAILED`.
## Status
- Wave 7.2 (final network-migration package).
- Source files all ≤300 LOC per CLAUDE.md.
- Every component carries Storybook stories (default + playground +
variants) per CLAUDE.md.

View File

@@ -0,0 +1,63 @@
{
"name": "@suaveplan/react-network-libp2p-demo",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "React-tier demo UI for @suaveplan/network-transport-libp2p. Decomposed into per-concern subcomponents (status, controls, peer interaction, peer lists, log panel) each renderable standalone for Storybook isolation. Bootstrap list is a required React prop — no hardcoded public defaults.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"@suaveplan/network-transport-libp2p": "workspace:^"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/network": "workspace:^",
"@suaveplan/network-transport-libp2p": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@testing-library/react": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,2 @@
export { Libp2pDemo } from "./libp2p-demo.js";
export type { Libp2pDemoProps } from "./libp2p-demo.types.js";

View File

@@ -0,0 +1,84 @@
# `libp2p-demo`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Root composer for the React libp2p demo. Wires `useLibp2pDemo` to five
presentational subcomponents (`<NodeStatusPanel>`, `<NodeControls>`,
`<PeerInteractionPanel>`, two `<PeerList>`s, `<LogPanel>`) and owns the
two pieces of UI-local state (target-peer + message text) the
interaction panel needs. The bootstrap multiaddr list is a REQUIRED
React prop — the package never publishes a public default — so consumers
must explicitly pin the peers their deployment trusts.
## Features
- Forwards every `UseLibp2pDemoOptions` field except `nodeFactory` and
`bootstrap` straight to the hook.
- Bootstrap list is a required prop (empty array permitted for
mDNS-only / WebRTC-only flows).
- `nodeFactory` is required — the package never imports libp2p directly;
consumers wire the real stack inside the factory.
- `debug` toggles the log panel without losing the underlying buffer.
- `heading` lets a consumer relabel the H2 row above the panels.
- Wrapper `style` merges over the built-in centred layout.
## Usage: Basic Example
```tsx
import { Libp2pDemo } from "@suaveplan/react-network-libp2p-demo";
<Libp2pDemo nodeFactory={myFactory} bootstrap={["/dnsaddr/peer/p2p/Qm"]} />;
```
## Usage: Advanced Example
```tsx
import { Libp2pDemo } from "@suaveplan/react-network-libp2p-demo";
function Page() {
return (
<Libp2pDemo
nodeFactory={buildLibp2pAdapter()}
bootstrap={[
"/dnsaddr/bootstrap-a/p2p/Qm…",
"/dnsaddr/bootstrap-b/p2p/Qm…",
]}
subTransport="webrtc"
listen={["/webrtc"]}
maxConnections={32}
dialTimeoutMs={5000}
protocolId="/myapp/echo/1.0.0"
autoStart={true}
debug={true}
heading="MyApp Network Demo"
style={{ maxWidth: 800 }}
/>
);
}
```
## API Reference
- `Libp2pDemoProps` — `Omit<UseLibp2pDemoOptions, "nodeFactory" | "bootstrap"> & {
nodeFactory; bootstrap; debug?; heading?; style? }`.
- `Libp2pDemo(props): ReactElement`.
## Implementation Notes
The composer is intentionally small: every panel is a self-contained
presentational component that can be rendered standalone in Storybook
with mocked data, while the composer's only job is to project the hook's
state into the props each panel expects. Two pieces of input state
(`targetPeer`, `messageText`) live in `useState` rather than in the hook
because they belong to the UI, not the libp2p layer — a future consumer
that wants its own UI for the interaction panel can ignore the composer
entirely and call the hook + subcomponents directly. Bootstrap is
required by deliberate API design: hardcoding a public list inside a
library would push a default trust decision onto every consumer, so the
package always errors towards explicit ownership. The composer never
imports anything from libp2p — production consumers wrap the real
`createLibp2p` call inside a factory adapter conforming to
`Libp2pDemoNodeFactory`.

View File

@@ -0,0 +1,56 @@
import type { ReactElement } from "react";
import {
createFakeNode,
fakeNodeFactory,
} from "../../../tests/setup/fake-node.js";
import { Libp2pDemo } from "./libp2p-demo.js";
export default {
title: "react-network-libp2p-demo/Libp2pDemo",
component: Libp2pDemo,
};
const factory = fakeNodeFactory(
createFakeNode({ peerId: "12D3KooWStorybook" })
);
export const Default = (): ReactElement => (
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
export const Playground = (): ReactElement => (
<Libp2pDemo
nodeFactory={factory}
bootstrap={["/dnsaddr/example.test/p2p/QmPlayground"]}
subTransport="webrtc"
listen={["/webrtc"]}
debug={true}
heading="libp2p — Playground"
/>
);
export const WithoutDebug = (): ReactElement => (
<Libp2pDemo
nodeFactory={factory}
bootstrap={[]}
debug={false}
heading="libp2p — Quiet Mode"
/>
);
export const AutoStart = (): ReactElement => (
<Libp2pDemo
nodeFactory={factory}
bootstrap={[]}
autoStart={true}
heading="libp2p — Auto-started"
/>
);
export const MinimalBootstrap = (): ReactElement => (
<Libp2pDemo
nodeFactory={factory}
bootstrap={["/dnsaddr/bootstrap.example/p2p/QmMinimal"]}
debug={true}
/>
);

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import {
createFakeNode,
fakeNodeFactory,
} from "../../../tests/setup/fake-node.js";
import { Libp2pDemo } from "./libp2p-demo.js";
describe("Libp2pDemo", () => {
it("renders heading, panels, and zero-state peer lists", () => {
const factory = fakeNodeFactory();
const { getByTestId, getAllByTestId } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
expect(getByTestId("libp2p-demo")).toBeTruthy();
expect(getByTestId("node-status-panel")).toBeTruthy();
expect(getByTestId("node-controls")).toBeTruthy();
expect(getByTestId("peer-interaction-panel")).toBeTruthy();
expect(getAllByTestId("peer-list").length).toBe(2);
expect(getByTestId("log-panel")).toBeTruthy();
});
it("hides log panel when debug=false", () => {
const factory = fakeNodeFactory();
const { queryByTestId } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} debug={false} />
);
expect(queryByTestId("log-panel")).toBeNull();
});
it("starts the node when Start clicked and updates status", async () => {
const node = createFakeNode({ peerId: "12D3KooWAlice" });
const factory = fakeNodeFactory(node);
const { getByTestId, user } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
await user.click(getByTestId("node-controls-start"));
expect(node.startedCount()).toBe(1);
expect(getByTestId("node-status-value").textContent).toBe("Running");
expect(getByTestId("node-status-peer-id").textContent).toBe(
"12D3KooWAlice"
);
});
it("propagates a discovered peer into the discovered list", async () => {
const node = createFakeNode();
const factory = fakeNodeFactory(node);
const { getByTestId, user, getAllByTestId } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
await user.click(getByTestId("node-controls-start"));
await act(async () => {
node.emitDiscovery({ peerId: "12D3KooWPeerOne" });
});
const lists = getAllByTestId("peer-list");
expect(lists[0]?.textContent).toContain("Discovered Peers (1)");
});
it("forwards heading + wrapper style overrides", () => {
const factory = fakeNodeFactory();
const { getByTestId } = renderComponent(
<Libp2pDemo
nodeFactory={factory}
bootstrap={[]}
heading="Custom"
style={{ padding: 0 }}
/>
);
const wrapper = getByTestId("libp2p-demo") as HTMLElement;
expect(wrapper.textContent).toContain("Custom");
expect(wrapper.style.padding).toBe("0px");
});
it("forwards all hook options to the node factory", async () => {
const node = createFakeNode();
const factory = fakeNodeFactory(node);
const { getByTestId, user } = renderComponent(
<Libp2pDemo
nodeFactory={factory}
bootstrap={["/dnsaddr/example/p2p/Qm"]}
subTransport="webrtc"
listen={["/webrtc"]}
maxConnections={8}
dialTimeoutMs={500}
protocolId="/x/1.0.0"
maxLogEntries={50}
clock={() => 1234}
/>
);
await user.click(getByTestId("node-controls-start"));
const cfg = node.lastConfig();
expect(cfg).toEqual({
bootstrap: ["/dnsaddr/example/p2p/Qm"],
subTransport: "webrtc",
listen: ["/webrtc"],
maxConnections: 8,
dialTimeoutMs: 500,
protocolId: "/x/1.0.0",
});
});
it("autoStart triggers the underlying node on mount", async () => {
const node = createFakeNode();
const factory = fakeNodeFactory(node);
renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} autoStart={true} />
);
// Auto-start microtask + node.start() promise.
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(node.startedCount()).toBe(1);
});
it("wires Stop button to demo.stop()", async () => {
const node = createFakeNode();
const factory = fakeNodeFactory(node);
const { getByTestId, user } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
await user.click(getByTestId("node-controls-start"));
await user.click(getByTestId("node-controls-stop"));
expect(node.stoppedCount()).toBe(1);
});
it("wires dial / ping / echo onClick through the hook", async () => {
const node = createFakeNode({ pingResult: 9 });
const factory = fakeNodeFactory(node);
const { getByTestId, user } = renderComponent(
<Libp2pDemo nodeFactory={factory} bootstrap={[]} />
);
await user.click(getByTestId("node-controls-start"));
await user.type(getByTestId("peer-interaction-target"), "X");
await user.click(getByTestId("peer-interaction-dial"));
await user.click(getByTestId("peer-interaction-ping"));
await user.click(getByTestId("peer-interaction-echo"));
// Ping result lands in the status panel.
expect(getByTestId("node-status-ping").textContent).toBe("9ms");
});
});

View File

@@ -0,0 +1,110 @@
import { type CSSProperties, type ReactElement, useState } from "react";
import { useLibp2pDemo } from "../../hooks/use-libp2p-demo/use-libp2p-demo.js";
import type { UseLibp2pDemoOptions } from "../../hooks/use-libp2p-demo/use-libp2p-demo.types.js";
import { LogPanel } from "../log-panel/log-panel.js";
import { NodeControls } from "../node-controls/node-controls.js";
import { NodeStatusPanel } from "../node-status-panel/node-status-panel.js";
import { PeerInteractionPanel } from "../peer-interaction-panel/peer-interaction-panel.js";
import { PeerList } from "../peer-list/peer-list.js";
import type { Libp2pDemoProps } from "./libp2p-demo.types.js";
type Writable<T> = { -readonly [K in keyof T]: T[K] };
const WRAPPER_STYLE: CSSProperties = {
fontFamily: "Arial, sans-serif",
maxWidth: 1200,
margin: "0 auto",
padding: 20,
};
/**
* Root composer for the libp2p demo. Owns the in-panel input state
* (target peer + message text) and routes every action through the
* `useLibp2pDemo` hook. The hook in turn forwards the user-supplied
* `nodeFactory` and bootstrap list to the underlying libp2p stack —
* the component never hardcodes a bootstrap default.
*/
export function Libp2pDemo(props: Libp2pDemoProps): ReactElement {
const {
nodeFactory,
bootstrap,
subTransport,
listen,
maxConnections,
dialTimeoutMs,
protocolId,
autoStart,
maxLogEntries,
clock,
debug = true,
heading = "libp2p Network Demo",
style,
} = props;
const demoOptions: Writable<UseLibp2pDemoOptions> = {
nodeFactory,
bootstrap,
};
if (subTransport !== undefined) demoOptions.subTransport = subTransport;
if (listen !== undefined) demoOptions.listen = listen;
if (maxConnections !== undefined)
demoOptions.maxConnections = maxConnections;
if (dialTimeoutMs !== undefined) demoOptions.dialTimeoutMs = dialTimeoutMs;
if (protocolId !== undefined) demoOptions.protocolId = protocolId;
if (autoStart !== undefined) demoOptions.autoStart = autoStart;
if (maxLogEntries !== undefined) demoOptions.maxLogEntries = maxLogEntries;
if (clock !== undefined) demoOptions.clock = clock;
const demo = useLibp2pDemo(demoOptions);
const [targetPeer, setTargetPeer] = useState("");
const [messageText, setMessageText] = useState("Hello from libp2p!");
return (
<div
data-testid="libp2p-demo"
style={style ? { ...WRAPPER_STYLE, ...style } : WRAPPER_STYLE}
>
<h2>{heading}</h2>
<NodeStatusPanel
isStarted={demo.isStarted}
peerId={demo.peerId}
discoveredCount={demo.discoveredPeers.length}
connectedCount={demo.connectedPeers.length}
listenCount={demo.listenAddresses.length}
lastPingMs={demo.lastPingMs}
/>
<NodeControls
isStarted={demo.isStarted}
onStart={() => void demo.start()}
onStop={() => void demo.stop()}
onClearLogs={demo.clearLogs}
/>
<PeerInteractionPanel
isStarted={demo.isStarted}
targetPeer={targetPeer}
messageText={messageText}
onTargetPeerChange={setTargetPeer}
onMessageTextChange={setMessageText}
onDial={(t) => void demo.dial(t)}
onPing={(t) => void demo.ping(t)}
onEcho={(t, m) => void demo.echo(t, m)}
/>
<div style={{ display: "flex", gap: 20, marginBottom: 20 }}>
<PeerList
title="Discovered Peers"
peers={demo.discoveredPeers}
emptyMessage="No peers discovered yet"
onSelect={setTargetPeer}
/>
<PeerList
title="Connected Peers"
peers={demo.connectedPeers}
emptyMessage="No peers connected"
onSelect={setTargetPeer}
highlightConnected={false}
/>
</div>
<LogPanel entries={demo.logs} visible={debug} />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { CSSProperties } from "react";
import type {
Libp2pDemoNodeFactory,
UseLibp2pDemoOptions,
} from "../../hooks/use-libp2p-demo/use-libp2p-demo.types.js";
/**
* Public props for the root `<Libp2pDemo>` composer. `bootstrap` is
* REQUIRED — the package publishes no public-bootstrap defaults so
* consumers must explicitly supply the list (empty array is permitted
* for mDNS-only / WebRTC-only flows).
*/
export interface Libp2pDemoProps
extends Omit<UseLibp2pDemoOptions, "nodeFactory" | "bootstrap"> {
/** REQUIRED libp2p demo-node factory. */
readonly nodeFactory: Libp2pDemoNodeFactory;
/** REQUIRED bootstrap multiaddr list. No public defaults. */
readonly bootstrap: readonly string[];
/** Show the debug log panel (default true). */
readonly debug?: boolean;
/** Custom heading rendered above the panels. */
readonly heading?: string;
/** Wrapper style override. */
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { LogPanel } from "./log-panel.js";
export type { LogPanelProps } from "./log-panel.types.js";

View File

@@ -0,0 +1,77 @@
# `log-panel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Append-only debug-log viewer for the libp2p demo. Renders the
`useLibp2pDemo` hook's `logs` array as timestamped, level-coloured rows
inside a fixed-height scroll container. Pure presentational component —
retention, ordering, and id generation live in the hook layer, so the
panel never owns mutable log state.
## Features
- Four-level colour coding (info / error / warning / success), overrideable
via the `levelColor` prop.
- Timestamp rendering is injectable through `formatTimestamp` for
locale-aware UI or test determinism; default shows ISO `HH:mm:ss.SSS`.
- Configurable `maxHeight` (defaults to 300 px) for the scroll container.
- Empty-state placeholder (`No log entries`) when the array is empty.
- `visible` flag returns `null` so consumers can toggle the panel out of
the tree without losing the underlying log buffer.
- Stable `data-testid` per row keyed on entry id.
## Usage: Basic Example
```tsx
import { LogPanel } from "@suaveplan/react-network-libp2p-demo";
<LogPanel entries={demo.logs} />;
```
## Usage: Advanced Example
```tsx
import { LogPanel } from "@suaveplan/react-network-libp2p-demo";
function Logs({ demo }: { demo: UseLibp2pDemoResult }) {
return (
<LogPanel
entries={demo.logs}
visible={demo.logs.length > 0}
maxHeight={500}
formatTimestamp={(ts) =>
new Date(ts).toLocaleTimeString("en-GB", {
hour12: false,
})
}
levelColor={(level) =>
level === "error" ? "#c0392b" : "#2c3e50"
}
/>
);
}
```
## API Reference
- `LogPanelProps` — `{ entries, visible?, maxHeight?, formatTimestamp?,
levelColor?, style? }`.
- `LogPanel(props): ReactElement | null` — returns `null` when
`visible === false`.
## Implementation Notes
The default timestamp formatter slices `Date.toISOString()` to extract
`HH:mm:ss.SSS` because that is locale-independent and renders identically
in every test environment; consumers wanting locale-aware times should
pass `formatTimestamp`. The level-colour palette intentionally uses pure
keyword colours (`red`, `green`, `orange`, `black`) rather than hex
because the panel is a debugging tool, not a branded surface — overrides
are easy when needed. The empty-state placeholder is rendered inside the
scroll container so it shifts with the same vertical rhythm as real
entries. Setting `visible={false}` returns `null` rather than
`display: none`; this keeps the DOM tree slimmer in the inactive case
without disturbing the underlying log buffer in the parent.

View File

@@ -0,0 +1,66 @@
import type { ReactElement } from "react";
import type { LogEntry } from "../../types/log-entry.types.js";
import { LogPanel } from "./log-panel.js";
export default {
title: "react-network-libp2p-demo/LogPanel",
component: LogPanel,
};
const now = Date.UTC(2026, 4, 12, 10, 0, 0);
const entries: LogEntry[] = [
{
id: "1",
timestamp: now,
level: "info",
message: "Creating libp2p node…",
},
{
id: "2",
timestamp: now + 250,
level: "success",
message: "Node started with id: 12D3KooW…",
},
{
id: "3",
timestamp: now + 1500,
level: "warning",
message: "Disconnected from peer: 12D3KooW…",
},
{
id: "4",
timestamp: now + 3200,
level: "error",
message: "Dial failed: connection refused",
},
];
export const Default = (): ReactElement => <LogPanel entries={[]} />;
export const Playground = (): ReactElement => (
<LogPanel entries={entries} maxHeight={240} style={{ width: 600 }} />
);
export const Hidden = (): ReactElement => (
<LogPanel entries={entries} visible={false} />
);
export const ErrorsOnly = (): ReactElement => (
<LogPanel
entries={entries.filter((e) => e.level === "error")}
maxHeight={120}
/>
);
export const CustomColors = (): ReactElement => (
<LogPanel
entries={entries}
levelColor={(level) =>
level === "error"
? "#c0392b"
: level === "warning"
? "#d35400"
: "#2c3e50"
}
/>
);

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import type { LogEntry } from "../../types/log-entry.types.js";
import { LogPanel } from "./log-panel.js";
const entry: LogEntry = {
id: "1",
timestamp: Date.UTC(2026, 4, 10, 12, 34, 56, 789),
level: "info",
message: "hello",
};
describe("LogPanel", () => {
it("renders empty placeholder when entries empty", () => {
const { getByTestId } = renderComponent(<LogPanel entries={[]} />);
expect(getByTestId("log-panel-empty").textContent).toBe(
"No log entries"
);
});
it("renders entries with default timestamp + level color", () => {
const { getByTestId } = renderComponent(<LogPanel entries={[entry]} />);
const row = getByTestId(`log-panel-row-${entry.id}`) as HTMLElement;
expect(row.textContent).toContain("INFO: hello");
expect(row.textContent).toContain("12:34:56");
expect(row.style.color).toBe("black");
});
it("returns null when invisible", () => {
const { queryByTestId } = renderComponent(
<LogPanel entries={[entry]} visible={false} />
);
expect(queryByTestId("log-panel")).toBeNull();
});
it("uses default body height when none provided", () => {
const { getByTestId } = renderComponent(<LogPanel entries={[]} />);
const body = getByTestId("log-panel-body") as HTMLElement;
expect(body.style.height).toBe("300px");
});
it("falls through to no inline style on container when style omitted", () => {
const { getByTestId } = renderComponent(<LogPanel entries={[]} />);
const node = getByTestId("log-panel") as HTMLElement;
expect(node.getAttribute("style")).toBeNull();
});
it("uses custom maxHeight, formatTimestamp, and levelColor", () => {
const { getByTestId } = renderComponent(
<LogPanel
entries={[
{ ...entry, level: "error", id: "x" },
{ ...entry, level: "warning", id: "y" },
{ ...entry, level: "success", id: "z" },
]}
maxHeight={123}
formatTimestamp={() => "T"}
levelColor={(l) =>
l === "error"
? "purple"
: l === "warning"
? "yellow"
: "blue"
}
style={{ width: 200 }}
/>
);
const body = getByTestId("log-panel-body") as HTMLElement;
expect(body.style.height).toBe("123px");
const rowX = getByTestId("log-panel-row-x") as HTMLElement;
expect(rowX.textContent).toContain("[T]");
expect(rowX.style.color).toBe("purple");
const rowY = getByTestId("log-panel-row-y") as HTMLElement;
expect(rowY.style.color).toBe("yellow");
const rowZ = getByTestId("log-panel-row-z") as HTMLElement;
expect(rowZ.style.color).toBe("blue");
});
it("applies user style to outer container", () => {
const { getByTestId } = renderComponent(
<LogPanel entries={[]} style={{ marginTop: 16 }} />
);
const node = getByTestId("log-panel") as HTMLElement;
expect(node.style.marginTop).toBe("16px");
});
});

View File

@@ -0,0 +1,73 @@
import type { CSSProperties, ReactElement } from "react";
import type { LogLevel } from "../../types/log-entry.types.js";
import type { LogPanelProps } from "./log-panel.types.js";
const CONTAINER_STYLE: CSSProperties = {
overflowY: "auto",
border: "1px solid #ddd",
padding: 10,
borderRadius: 4,
backgroundColor: "#f8f9fa",
fontFamily: "monospace",
fontSize: 12,
};
const DEFAULT_LEVEL_COLOR: Record<LogLevel, string> = {
info: "black",
error: "red",
warning: "orange",
success: "green",
};
const defaultFormatTimestamp = (ts: number): string =>
new Date(ts).toISOString().substring(11, 23);
const defaultLevelColor = (level: LogLevel): string =>
DEFAULT_LEVEL_COLOR[level];
/**
* Append-only debug log panel. Renders entries in arrival order; the
* caller controls retention (the hook keeps the most recent N).
*/
export function LogPanel(props: LogPanelProps): ReactElement | null {
const {
entries,
visible = true,
maxHeight = 300,
formatTimestamp = defaultFormatTimestamp,
levelColor = defaultLevelColor,
style,
} = props;
if (!visible) return null;
return (
<div data-testid="log-panel" style={style ?? undefined}>
<h3>Debug Logs</h3>
<div
data-testid="log-panel-body"
style={{ ...CONTAINER_STYLE, height: maxHeight }}
>
{entries.length === 0 && (
<div
data-testid="log-panel-empty"
style={{ color: "#999" }}
>
No log entries
</div>
)}
{entries.map((entry) => (
<div
data-testid={`log-panel-row-${entry.id}`}
key={entry.id}
style={{
color: levelColor(entry.level),
marginBottom: 2,
}}
>
[{formatTimestamp(entry.timestamp)}]{" "}
{entry.level.toUpperCase()}: {entry.message}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { CSSProperties } from "react";
import type { LogEntry, LogLevel } from "../../types/log-entry.types.js";
export interface LogPanelProps {
readonly entries: readonly LogEntry[];
readonly visible?: boolean;
readonly maxHeight?: number;
readonly formatTimestamp?: (timestamp: number) => string;
readonly levelColor?: (level: LogLevel) => string;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { NodeControls } from "./node-controls.js";
export type { NodeControlsProps } from "./node-controls.types.js";

View File

@@ -0,0 +1,83 @@
# `node-controls`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Lifecycle button row for the libp2p demo: Start Node, Stop Node, Clear
Logs. Pure presentational subcomponent — each action is a prop callback,
so a host can wire any handler (real demo hook, Storybook noop, test spy).
The disabled state mirrors the demo's `isStarted` boolean so users can
never trigger an action the underlying node would reject.
## Features
- Three buttons with stable `data-testid`s (`node-controls-start`,
`node-controls-stop`, `node-controls-clear-logs`) for interaction tests.
- Start button disabled when `isStarted === true`.
- Stop button disabled when `isStarted === false`.
- Clear-logs button always enabled (clearing is safe at any lifecycle
phase).
- Colour cue per state (`#28a745` green / `#dc3545` red / `#6c757d` grey
for clear).
- Container style override.
## Usage: Basic Example
```tsx
import { NodeControls } from "@suaveplan/react-network-libp2p-demo";
<NodeControls
isStarted={false}
onStart={() => void demo.start()}
onStop={() => void demo.stop()}
onClearLogs={demo.clearLogs}
/>;
```
## Usage: Advanced Example
```tsx
import { NodeControls } from "@suaveplan/react-network-libp2p-demo";
function ControlsBar({ demo }: { demo: UseLibp2pDemoResult }) {
return (
<NodeControls
isStarted={demo.isStarted}
onStart={() => void demo.start()}
onStop={() => void demo.stop()}
onClearLogs={demo.clearLogs}
style={{
marginBottom: 0,
padding: 8,
background: "#fafafa",
borderRadius: 4,
}}
/>
);
}
```
## API Reference
- `NodeControlsProps` — `{ isStarted, onStart, onStop, onClearLogs,
style? }`.
- `NodeControls(props): ReactElement` — always renders all three
buttons; visibility is controlled by `disabled`, never by conditional
rendering, so layout never reflows.
## Implementation Notes
The component never renders fewer than three buttons. Hiding them
on lifecycle change would jank the layout and confuse keyboard focus.
Instead it disables and recolours, matching the upstream demo's UX. The
clear-logs button is always enabled because clearing local UI state has
no contraindication — separating it from the lifecycle pair also lets a
test driver assert on a single isolated action without provisioning a
running node first. Colour values match Bootstrap-3 convention so the
demo reads naturally even outside any theme. The container `style` prop
is applied directly (not merged through a base) so a consumer can fully
restyle the row without contortions; the buttons keep their built-in
styling because button-by-button override would have multiplied the
prop surface needlessly.

View File

@@ -0,0 +1,37 @@
import type { ReactElement } from "react";
import { NodeControls } from "./node-controls.js";
export default {
title: "react-network-libp2p-demo/NodeControls",
component: NodeControls,
};
const noop = (): void => undefined;
export const Default = (): ReactElement => (
<NodeControls
isStarted={false}
onStart={noop}
onStop={noop}
onClearLogs={noop}
/>
);
export const Playground = (): ReactElement => (
<NodeControls
isStarted={true}
onStart={noop}
onStop={noop}
onClearLogs={noop}
style={{ marginBottom: 0, padding: 8, background: "#f3f3f3" }}
/>
);
export const Running = (): ReactElement => (
<NodeControls
isStarted={true}
onStart={noop}
onStop={noop}
onClearLogs={noop}
/>
);

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { NodeControls } from "./node-controls.js";
describe("NodeControls", () => {
it("invokes onStart when start button clicked", async () => {
let started = 0;
const { getByTestId, user } = renderComponent(
<NodeControls
isStarted={false}
onStart={() => {
started += 1;
}}
onStop={() => undefined}
onClearLogs={() => undefined}
/>
);
await user.click(getByTestId("node-controls-start"));
expect(started).toBe(1);
});
it("invokes onStop and onClearLogs", async () => {
let stopped = 0;
let cleared = 0;
const { getByTestId, user } = renderComponent(
<NodeControls
isStarted={true}
onStart={() => undefined}
onStop={() => {
stopped += 1;
}}
onClearLogs={() => {
cleared += 1;
}}
/>
);
await user.click(getByTestId("node-controls-stop"));
await user.click(getByTestId("node-controls-clear-logs"));
expect(stopped).toBe(1);
expect(cleared).toBe(1);
});
it("disables start when running and stop when stopped", () => {
const { getByTestId, rerender } = renderComponent(
<NodeControls
isStarted={false}
onStart={() => undefined}
onStop={() => undefined}
onClearLogs={() => undefined}
/>
);
expect(
(getByTestId("node-controls-start") as HTMLButtonElement).disabled
).toBe(false);
expect(
(getByTestId("node-controls-stop") as HTMLButtonElement).disabled
).toBe(true);
rerender(
<NodeControls
isStarted={true}
onStart={() => undefined}
onStop={() => undefined}
onClearLogs={() => undefined}
/>
);
expect(
(getByTestId("node-controls-start") as HTMLButtonElement).disabled
).toBe(true);
expect(
(getByTestId("node-controls-stop") as HTMLButtonElement).disabled
).toBe(false);
});
it("applies user style overrides on the container", () => {
const { getByTestId } = renderComponent(
<NodeControls
isStarted={false}
onStart={() => undefined}
onStop={() => undefined}
onClearLogs={() => undefined}
style={{ marginBottom: 99 }}
/>
);
const node = getByTestId("node-controls") as HTMLElement;
expect(node.style.marginBottom).toBe("99px");
});
});

View File

@@ -0,0 +1,62 @@
import type { CSSProperties, ReactElement } from "react";
import type { NodeControlsProps } from "./node-controls.types.js";
const BUTTON_BASE: CSSProperties = {
padding: "10px 20px",
color: "white",
border: "none",
borderRadius: "4px",
marginRight: "10px",
};
/**
* Three lifecycle buttons (start / stop / clear-logs). Buttons are
* disabled in proportion to `isStarted` so the user cannot start an
* already-running node or stop a stopped one.
*/
export function NodeControls(props: NodeControlsProps): ReactElement {
const { isStarted, onStart, onStop, onClearLogs, style } = props;
return (
<div data-testid="node-controls" style={style ?? { marginBottom: 20 }}>
<button
type="button"
data-testid="node-controls-start"
onClick={onStart}
disabled={isStarted}
style={{
...BUTTON_BASE,
backgroundColor: isStarted ? "#ccc" : "#28a745",
cursor: isStarted ? "not-allowed" : "pointer",
}}
>
Start Node
</button>
<button
type="button"
data-testid="node-controls-stop"
onClick={onStop}
disabled={!isStarted}
style={{
...BUTTON_BASE,
backgroundColor: isStarted ? "#dc3545" : "#ccc",
cursor: isStarted ? "pointer" : "not-allowed",
}}
>
Stop Node
</button>
<button
type="button"
data-testid="node-controls-clear-logs"
onClick={onClearLogs}
style={{
...BUTTON_BASE,
backgroundColor: "#6c757d",
cursor: "pointer",
marginRight: 0,
}}
>
Clear Logs
</button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import type { CSSProperties } from "react";
export interface NodeControlsProps {
readonly isStarted: boolean;
readonly onStart: () => void;
readonly onStop: () => void;
readonly onClearLogs: () => void;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { NodeStatusPanel } from "./node-status-panel.js";
export type { NodeStatusPanelProps } from "./node-status-panel.types.js";

View File

@@ -0,0 +1,77 @@
# `node-status-panel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Presentational status block for the libp2p demo. Shows whether the node is
running, its peer id, peer + listen-address counts, and the most recent
ping result. Pure props-in / DOM-out — owns no state, so Storybook can
render every state combination from static mock data without spinning a
real libp2p node.
## Features
- Coloured background (`#e7f5e7` running / `#ffe7e7` stopped) drives the
primary status signal pre-cognitively.
- Hides the peer-id row when `peerId === ""` so the panel collapses
cleanly while the node is stopped.
- Hides the last-ping row when `lastPingMs` is `null` or `undefined`.
- Renders every dynamic value behind a `data-testid` so interaction tests
can assert without DOM-string fragility.
- Accepts a `style` override that wins over the built-in background.
## Usage: Basic Example
```tsx
import { NodeStatusPanel } from "@suaveplan/react-network-libp2p-demo";
<NodeStatusPanel
isStarted={false}
peerId=""
discoveredCount={0}
connectedCount={0}
listenCount={0}
/>;
```
## Usage: Advanced Example
```tsx
import { NodeStatusPanel } from "@suaveplan/react-network-libp2p-demo";
function Status({ demo }: { demo: UseLibp2pDemoResult }) {
return (
<NodeStatusPanel
isStarted={demo.isStarted}
peerId={demo.peerId}
discoveredCount={demo.discoveredPeers.length}
connectedCount={demo.connectedPeers.length}
listenCount={demo.listenAddresses.length}
lastPingMs={demo.lastPingMs}
style={{ borderColor: "#888" }}
/>
);
}
```
## API Reference
- `NodeStatusPanelProps` — `{ isStarted, peerId, discoveredCount,
connectedCount, listenCount, lastPingMs?, style? }`.
- `NodeStatusPanel(props): ReactElement` — never returns `null`.
## Implementation Notes
The component is intentionally dumb: every value rendered is a prop. The
hook layer (`useLibp2pDemo`) is responsible for projecting state into the
shape this panel expects, which keeps the panel reusable in tests and
Storybook regardless of the underlying libp2p plumbing. Peer-id is wrapped
in a `<code>` element so long base58 strings keep their monospace identity
without wrapping awkwardly inside the surrounding flow. The merged style
order is base → status-coloured → user-supplied, so consumers can stamp
in a darker theme without losing the running/stopped colour cue unless
they override `backgroundColor` explicitly. Every data-testid is stable
across renders, which means snapshot tests should anchor on the testid
rather than DOM hierarchy.

View File

@@ -0,0 +1,49 @@
import type { ReactElement } from "react";
import { NodeStatusPanel } from "./node-status-panel.js";
export default {
title: "react-network-libp2p-demo/NodeStatusPanel",
component: NodeStatusPanel,
};
export const Default = (): ReactElement => (
<NodeStatusPanel
isStarted={false}
peerId=""
discoveredCount={0}
connectedCount={0}
listenCount={0}
/>
);
export const Playground = (): ReactElement => (
<NodeStatusPanel
isStarted={true}
peerId="12D3KooWPlaygroundExample"
discoveredCount={5}
connectedCount={3}
listenCount={4}
lastPingMs={48}
/>
);
export const Running = (): ReactElement => (
<NodeStatusPanel
isStarted={true}
peerId="12D3KooWAlice"
discoveredCount={12}
connectedCount={4}
listenCount={2}
/>
);
export const PingFresh = (): ReactElement => (
<NodeStatusPanel
isStarted={true}
peerId="12D3KooWAlice"
discoveredCount={1}
connectedCount={1}
listenCount={1}
lastPingMs={17}
/>
);

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { NodeStatusPanel } from "./node-status-panel.js";
describe("NodeStatusPanel", () => {
it("renders stopped state with no peer id", () => {
const { getByTestId, queryByTestId } = renderComponent(
<NodeStatusPanel
isStarted={false}
peerId=""
discoveredCount={0}
connectedCount={0}
listenCount={0}
/>
);
expect(getByTestId("node-status-value").textContent).toBe("Stopped");
expect(queryByTestId("node-status-peer-id")).toBeNull();
expect(queryByTestId("node-status-ping")).toBeNull();
});
it("renders running state with peer id + last ping", () => {
const { getByTestId } = renderComponent(
<NodeStatusPanel
isStarted={true}
peerId="12D3KooWAAA"
discoveredCount={3}
connectedCount={2}
listenCount={4}
lastPingMs={42}
/>
);
expect(getByTestId("node-status-value").textContent).toBe("Running");
expect(getByTestId("node-status-peer-id").textContent).toBe(
"12D3KooWAAA"
);
expect(getByTestId("node-status-discovered").textContent).toBe("3");
expect(getByTestId("node-status-connected").textContent).toBe("2");
expect(getByTestId("node-status-listen").textContent).toBe("4");
expect(getByTestId("node-status-ping").textContent).toBe("42ms");
});
it("omits ping block when null", () => {
const { queryByTestId } = renderComponent(
<NodeStatusPanel
isStarted={true}
peerId="x"
discoveredCount={0}
connectedCount={0}
listenCount={0}
lastPingMs={null}
/>
);
expect(queryByTestId("node-status-ping")).toBeNull();
});
it("merges user style overrides", () => {
const { getByTestId } = renderComponent(
<NodeStatusPanel
isStarted={true}
peerId=""
discoveredCount={0}
connectedCount={0}
listenCount={0}
style={{ background: "rgb(1, 2, 3)" }}
/>
);
const node = getByTestId("node-status-panel") as HTMLElement;
expect(node.style.background).toBe("rgb(1, 2, 3)");
});
});

View File

@@ -0,0 +1,70 @@
import type { CSSProperties, ReactElement } from "react";
import type { NodeStatusPanelProps } from "./node-status-panel.types.js";
const BASE_STYLE: CSSProperties = {
padding: "15px",
marginBottom: "20px",
border: "1px solid #ddd",
borderRadius: "8px",
};
/**
* Presentational status block. Pure props-in / DOM-out — no state of its
* own, so Storybook can render it standalone against mock data.
*/
export function NodeStatusPanel(props: NodeStatusPanelProps): ReactElement {
const {
isStarted,
peerId,
discoveredCount,
connectedCount,
listenCount,
lastPingMs,
style,
} = props;
const merged: CSSProperties = {
...BASE_STYLE,
backgroundColor: isStarted ? "#e7f5e7" : "#ffe7e7",
...(style ?? {}),
};
return (
<div data-testid="node-status-panel" style={merged}>
<div>
<strong>Status:</strong>{" "}
<span data-testid="node-status-value">
{isStarted ? "Running" : "Stopped"}
</span>
</div>
{peerId !== "" && (
<div>
<strong>Peer ID:</strong>{" "}
<code data-testid="node-status-peer-id">{peerId}</code>
</div>
)}
<div>
<strong>Discovered Peers:</strong>{" "}
<span data-testid="node-status-discovered">
{discoveredCount}
</span>
</div>
<div>
<strong>Connected Peers:</strong>{" "}
<span data-testid="node-status-connected">
{connectedCount}
</span>
</div>
<div>
<strong>Listening Addresses:</strong>{" "}
<span data-testid="node-status-listen">{listenCount}</span>
</div>
{lastPingMs !== null && lastPingMs !== undefined && (
<div>
<strong>Last Ping:</strong>{" "}
<span data-testid="node-status-ping">{lastPingMs}ms</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { CSSProperties } from "react";
export interface NodeStatusPanelProps {
readonly isStarted: boolean;
readonly peerId: string;
readonly discoveredCount: number;
readonly connectedCount: number;
readonly listenCount: number;
readonly lastPingMs?: number | null;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { PeerInteractionPanel } from "./peer-interaction-panel.js";
export type { PeerInteractionPanelProps } from "./peer-interaction-panel.types.js";

View File

@@ -0,0 +1,85 @@
# `peer-interaction-panel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Controlled peer-interaction row used by the libp2p demo. Hosts the
target-peer text input, the echo-message text input, and the three action
buttons (dial / ping / send-echo). The panel is fully controlled: every
input value and action handler lives in the parent, so the same component
renders identically inside the demo, inside a Storybook fixture, and
inside an interaction test.
## Features
- Two controlled text inputs (`targetPeer`, `messageText`).
- Three action buttons (`dial`, `ping`, `send-echo`).
- Dial + ping enabled only when the node is started AND a target is
populated.
- Send-echo additionally requires a non-empty message.
- Stable `data-testid` for every interactive element.
- Container style override.
## Usage: Basic Example
```tsx
import { PeerInteractionPanel } from "@suaveplan/react-network-libp2p-demo";
<PeerInteractionPanel
isStarted={true}
targetPeer="12D3KooW…"
messageText="hi"
onTargetPeerChange={setTarget}
onMessageTextChange={setMessage}
onDial={(t) => demo.dial(t)}
onPing={(t) => demo.ping(t)}
onEcho={(t, m) => demo.echo(t, m)}
/>;
```
## Usage: Advanced Example
```tsx
import { PeerInteractionPanel } from "@suaveplan/react-network-libp2p-demo";
function InteractionRow({ demo }: { demo: UseLibp2pDemoResult }) {
const [target, setTarget] = useState("");
const [message, setMessage] = useState("Hello from libp2p!");
return (
<PeerInteractionPanel
isStarted={demo.isStarted}
targetPeer={target}
messageText={message}
onTargetPeerChange={setTarget}
onMessageTextChange={setMessage}
onDial={(t) => void demo.dial(t)}
onPing={(t) => void demo.ping(t)}
onEcho={(t, m) => void demo.echo(t, m)}
/>
);
}
```
## API Reference
- `PeerInteractionPanelProps` — `{ isStarted, targetPeer, messageText,
onTargetPeerChange, onMessageTextChange, onDial, onPing, onEcho,
style? }`.
- `PeerInteractionPanel(props): ReactElement`.
## Implementation Notes
Every action button uses the same `buttonColor` + `buttonCursor` helpers
so adding a fourth action would be a 6-line change. The component
intentionally does not trim inputs before invoking callbacks — the parent
hook knows whether trimming makes sense for the underlying call (some
multiaddrs are sensitive to whitespace prefixes). The enabled/disabled
logic uses two derived booleans (`peerActionsEnabled` and `echoEnabled`)
which encode the three required props in one place — keeping that logic
inside the panel rather than the parent means consumers cannot
accidentally allow a click that the underlying hook would reject. The
panel renders both rows even when nothing is enabled because layout
stability matters more than minimising DOM during the demo's brief
stopped state.

View File

@@ -0,0 +1,65 @@
import { type ReactElement, useState } from "react";
import { PeerInteractionPanel } from "./peer-interaction-panel.js";
export default {
title: "react-network-libp2p-demo/PeerInteractionPanel",
component: PeerInteractionPanel,
};
const noop = (): void => undefined;
export const Default = (): ReactElement => (
<PeerInteractionPanel
isStarted={false}
targetPeer=""
messageText="Hello from libp2p!"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);
export const Playground = (): ReactElement => {
const [target, setTarget] = useState("");
const [message, setMessage] = useState("Hello!");
return (
<PeerInteractionPanel
isStarted={true}
targetPeer={target}
messageText={message}
onTargetPeerChange={setTarget}
onMessageTextChange={setMessage}
onDial={(t) => alert(`dial: ${t}`)}
onPing={(t) => alert(`ping: ${t}`)}
onEcho={(t, m) => alert(`echo: ${t} -> ${m}`)}
/>
);
};
export const RunningWithTarget = (): ReactElement => (
<PeerInteractionPanel
isStarted={true}
targetPeer="12D3KooWPeerSelected"
messageText="hello"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);
export const RunningEmptyTarget = (): ReactElement => (
<PeerInteractionPanel
isStarted={true}
targetPeer=""
messageText=""
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { type ReactElement, useState } from "react";
import { PeerInteractionPanel } from "./peer-interaction-panel.js";
const noop = (): void => undefined;
function Host(): ReactElement {
const [target, setTarget] = useState("");
const [message, setMessage] = useState("");
return (
<>
<PeerInteractionPanel
isStarted={true}
targetPeer={target}
messageText={message}
onTargetPeerChange={setTarget}
onMessageTextChange={setMessage}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
<output data-testid="host-target">{target}</output>
<output data-testid="host-message">{message}</output>
</>
);
}
describe("PeerInteractionPanel", () => {
it("forwards input changes to controlled callbacks", async () => {
const { getByTestId, user } = renderComponent(<Host />);
await user.type(getByTestId("peer-interaction-target"), "12D3");
expect(getByTestId("host-target").textContent).toBe("12D3");
await user.type(getByTestId("peer-interaction-message"), "hi");
expect(getByTestId("host-message").textContent).toBe("hi");
});
it("invokes dial / ping / echo with current values", async () => {
let dialed = "";
let pinged = "";
let echoed: [string, string] | null = null;
const { getByTestId, user } = renderComponent(
<PeerInteractionPanel
isStarted={true}
targetPeer="12D3KooW"
messageText="hello"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={(t) => {
dialed = t;
}}
onPing={(t) => {
pinged = t;
}}
onEcho={(t, m) => {
echoed = [t, m];
}}
/>
);
await user.click(getByTestId("peer-interaction-dial"));
await user.click(getByTestId("peer-interaction-ping"));
await user.click(getByTestId("peer-interaction-echo"));
expect(dialed).toBe("12D3KooW");
expect(pinged).toBe("12D3KooW");
expect(echoed).toEqual(["12D3KooW", "hello"]);
});
it("disables dial / ping when node stopped or target empty", () => {
const { getByTestId, rerender } = renderComponent(
<PeerInteractionPanel
isStarted={false}
targetPeer="12D3KooW"
messageText="hi"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);
expect(
(getByTestId("peer-interaction-dial") as HTMLButtonElement).disabled
).toBe(true);
rerender(
<PeerInteractionPanel
isStarted={true}
targetPeer=""
messageText="hi"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);
expect(
(getByTestId("peer-interaction-ping") as HTMLButtonElement).disabled
).toBe(true);
});
it("disables echo when message empty", () => {
const { getByTestId } = renderComponent(
<PeerInteractionPanel
isStarted={true}
targetPeer="12D3"
messageText=""
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
/>
);
expect(
(getByTestId("peer-interaction-echo") as HTMLButtonElement).disabled
).toBe(true);
});
it("applies user style override", () => {
const { getByTestId } = renderComponent(
<PeerInteractionPanel
isStarted={true}
targetPeer="x"
messageText="x"
onTargetPeerChange={noop}
onMessageTextChange={noop}
onDial={noop}
onPing={noop}
onEcho={noop}
style={{ marginBottom: 0, padding: 4 }}
/>
);
const node = getByTestId("peer-interaction-panel") as HTMLElement;
expect(node.style.marginBottom).toBe("0px");
});
});

View File

@@ -0,0 +1,124 @@
import type { CSSProperties, ReactElement } from "react";
import type { PeerInteractionPanelProps } from "./peer-interaction-panel.types.js";
const INPUT_STYLE: CSSProperties = {
padding: 8,
marginRight: 10,
border: "1px solid #ddd",
borderRadius: 4,
};
const BUTTON_STYLE: CSSProperties = {
padding: "8px 16px",
color: "white",
border: "none",
borderRadius: 4,
marginRight: 10,
};
function buttonColor(enabled: boolean, base: string): string {
return enabled ? base : "#ccc";
}
function buttonCursor(enabled: boolean): "pointer" | "not-allowed" {
return enabled ? "pointer" : "not-allowed";
}
/**
* Controlled peer-interaction row: target-peer input, message input,
* and three action buttons (dial / ping / send-echo). All state lives in
* the parent so Storybook can pin any combination of values.
*/
export function PeerInteractionPanel(
props: PeerInteractionPanelProps
): ReactElement {
const {
isStarted,
targetPeer,
messageText,
onTargetPeerChange,
onMessageTextChange,
onDial,
onPing,
onEcho,
style,
} = props;
const peerActionsEnabled = isStarted && targetPeer !== "";
const echoEnabled = peerActionsEnabled && messageText !== "";
return (
<div
data-testid="peer-interaction-panel"
style={style ?? { marginBottom: 20 }}
>
<h3>Peer Interaction</h3>
<div style={{ marginBottom: 10 }}>
<input
type="text"
data-testid="peer-interaction-target"
value={targetPeer}
onChange={(e) => onTargetPeerChange(e.target.value)}
placeholder="Enter peer id or multiaddr"
style={{ ...INPUT_STYLE, width: 400 }}
/>
<button
type="button"
data-testid="peer-interaction-dial"
onClick={() => onDial(targetPeer)}
disabled={!peerActionsEnabled}
style={{
...BUTTON_STYLE,
backgroundColor: buttonColor(
peerActionsEnabled,
"#007bff"
),
cursor: buttonCursor(peerActionsEnabled),
}}
>
Dial
</button>
<button
type="button"
data-testid="peer-interaction-ping"
onClick={() => onPing(targetPeer)}
disabled={!peerActionsEnabled}
style={{
...BUTTON_STYLE,
backgroundColor: buttonColor(
peerActionsEnabled,
"#28a745"
),
cursor: buttonCursor(peerActionsEnabled),
}}
>
Ping
</button>
</div>
<div style={{ marginBottom: 10 }}>
<input
type="text"
data-testid="peer-interaction-message"
value={messageText}
onChange={(e) => onMessageTextChange(e.target.value)}
placeholder="Message to echo"
style={{ ...INPUT_STYLE, width: 300 }}
/>
<button
type="button"
data-testid="peer-interaction-echo"
onClick={() => onEcho(targetPeer, messageText)}
disabled={!echoEnabled}
style={{
...BUTTON_STYLE,
marginRight: 0,
backgroundColor: buttonColor(echoEnabled, "#ffc107"),
cursor: buttonCursor(echoEnabled),
}}
>
Send Echo
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import type { CSSProperties } from "react";
export interface PeerInteractionPanelProps {
readonly isStarted: boolean;
readonly targetPeer: string;
readonly messageText: string;
readonly onTargetPeerChange: (next: string) => void;
readonly onMessageTextChange: (next: string) => void;
readonly onDial: (target: string) => void;
readonly onPing: (target: string) => void;
readonly onEcho: (target: string, message: string) => void;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,2 @@
export { PeerList } from "./peer-list.js";
export type { PeerListProps } from "./peer-list.types.js";

View File

@@ -0,0 +1,80 @@
# `peer-list`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reusable peer-list panel. Renders either discovered or connected peers
behind a single component — the caller supplies the title, the array, an
empty-state message, and the row-selection callback. Selecting a row
invokes `onSelect(peerId)` with the full (untruncated) peer id so the
caller can paste it directly into a dial/ping/echo command.
## Features
- Single component covers both lists used by the demo (Discovered + Connected).
- Truncates ids in the row label (`id.substring(0, 20) + "…"`) but passes
the full id to `onSelect`.
- Empty-state message customisable per use site.
- Connected rows can be highlighted (`highlightConnected`, default true).
- Each row carries `data-testid="peer-list-row-<peerId>"` so interaction
tests can target a single peer.
## Usage: Basic Example
```tsx
import { PeerList } from "@suaveplan/react-network-libp2p-demo";
<PeerList
title="Connected"
peers={demo.connectedPeers}
onSelect={setTargetPeer}
/>;
```
## Usage: Advanced Example
```tsx
import { PeerList } from "@suaveplan/react-network-libp2p-demo";
function Lists({ demo, onPick }: Props) {
return (
<div style={{ display: "flex", gap: 20 }}>
<PeerList
title="Discovered"
peers={demo.discoveredPeers}
emptyMessage="No peers discovered yet"
onSelect={onPick}
/>
<PeerList
title="Connected"
peers={demo.connectedPeers}
emptyMessage="No peers connected"
onSelect={onPick}
highlightConnected={false}
/>
</div>
);
}
```
## API Reference
- `PeerListProps` — `{ title, peers, emptyMessage?, onSelect,
highlightConnected?, style? }`.
- `PeerList(props): ReactElement`.
## Implementation Notes
A nested `<PeerRow>` component sits inside this file rather than under
its own folder because it is purely an implementation detail and has no
useful public surface. The truncation rule is a constant (20 + ellipsis)
matching the upstream demo; consumers wanting a different truncation
should compose the rows themselves. The component uses
`buttons` for every row so keyboard-only users get free focus
management — visually styled as rows but semantically a clickable list.
Highlight defaults to true because the most common consumer ("Discovered"
column) needs to distinguish discovered-only from discovered-and-connected
peers; the connected list typically passes `highlightConnected=false`
because every row would otherwise highlight redundantly.

View File

@@ -0,0 +1,69 @@
import type { ReactElement } from "react";
import type { PeerInfo } from "../../types/peer-info.types.js";
import { PeerList } from "./peer-list.js";
export default {
title: "react-network-libp2p-demo/PeerList",
component: PeerList,
};
const noop = (): void => undefined;
const peerA: PeerInfo = {
id: "12D3KooWPeerAlpha123456",
protocols: ["/ipfs/ping/1.0.0", "/suaveplan/demo/echo/1.0.0"],
addresses: ["/ip4/192.0.2.1/tcp/4001"],
connected: true,
};
const peerB: PeerInfo = {
id: "12D3KooWPeerBetaXXXXXX",
protocols: [],
addresses: ["/ip4/192.0.2.2/tcp/4001"],
connected: false,
};
const peerC: PeerInfo = {
id: "12D3KooWPeerGammaYYYY",
protocols: ["/ipfs/identify/1.0.0"],
addresses: ["/ip4/192.0.2.3/tcp/4001", "/ip4/192.0.2.4/tcp/4001"],
connected: false,
};
export const Default = (): ReactElement => (
<PeerList title="Discovered" peers={[]} onSelect={noop} />
);
export const Playground = (): ReactElement => (
<PeerList
title="Discovered Peers"
peers={[peerA, peerB, peerC]}
onSelect={(id) => alert(`selected ${id}`)}
style={{ width: 360 }}
/>
);
export const ConnectedOnly = (): ReactElement => (
<PeerList
title="Connected"
peers={[peerA]}
onSelect={noop}
emptyMessage="No peers connected"
/>
);
export const NoHighlight = (): ReactElement => (
<PeerList
title="Discovered"
peers={[peerA, peerB]}
onSelect={noop}
highlightConnected={false}
/>
);
export const EmptyCustomMessage = (): ReactElement => (
<PeerList
title="Discovered"
peers={[]}
onSelect={noop}
emptyMessage="No peers discovered yet"
/>
);

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import type { PeerInfo } from "../../types/peer-info.types.js";
import { PeerList } from "./peer-list.js";
const peerA: PeerInfo = {
id: "12D3KooWAAAAAAAAAAAAAAAAAAAAAA",
protocols: ["/x/1"],
addresses: ["/ip4/1.2.3.4/tcp/4001"],
connected: true,
};
const peerB: PeerInfo = {
id: "12D3KooWBBBBBBBBBBBBBBBBBBBBBB",
protocols: [],
addresses: [],
connected: false,
};
describe("PeerList", () => {
it("renders empty message when no peers", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Discovered"
peers={[]}
onSelect={() => undefined}
emptyMessage="None yet"
/>
);
expect(getByTestId("peer-list-empty").textContent).toBe("None yet");
expect(getByTestId("peer-list-title").textContent).toBe(
"Discovered (0)"
);
});
it("renders rows with truncated ids", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Connected"
peers={[peerA, peerB]}
onSelect={() => undefined}
/>
);
expect(getByTestId(`peer-list-row-${peerA.id}`).textContent).toContain(
"12D3KooWAAAAAAAAAAAA…"
);
});
it("uses default empty message when none provided", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Discovered"
peers={[]}
onSelect={() => undefined}
/>
);
expect(getByTestId("peer-list-empty").textContent).toBe("No peers");
});
it("falls back to default flex style when no style provided", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Discovered"
peers={[]}
onSelect={() => undefined}
/>
);
const node = getByTestId("peer-list") as HTMLElement;
expect(node.style.flex.startsWith("1")).toBe(true);
});
it("invokes onSelect with the full untruncated id", async () => {
let selected = "";
const { getByTestId, user } = renderComponent(
<PeerList
title="Discovered"
peers={[peerA]}
onSelect={(id) => {
selected = id;
}}
/>
);
await user.click(getByTestId(`peer-list-row-${peerA.id}`));
expect(selected).toBe(peerA.id);
});
it("highlights connected rows when highlightConnected=true (default)", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Discovered"
peers={[peerA, peerB]}
onSelect={() => undefined}
/>
);
const row = getByTestId(`peer-list-row-${peerA.id}`) as HTMLElement;
expect(row.style.backgroundColor.toLowerCase()).toBe("#e7f5e7");
});
it("skips highlight when highlightConnected=false", () => {
const { getByTestId } = renderComponent(
<PeerList
title="Discovered"
peers={[peerA]}
onSelect={() => undefined}
highlightConnected={false}
/>
);
const row = getByTestId(`peer-list-row-${peerA.id}`) as HTMLElement;
expect(row.style.backgroundColor).toBe("transparent");
});
it("does not truncate short ids", () => {
const short: PeerInfo = {
id: "abc",
protocols: [],
addresses: [],
connected: false,
};
const { getByTestId } = renderComponent(
<PeerList title="x" peers={[short]} onSelect={() => undefined} />
);
expect(getByTestId("peer-list-row-abc").textContent).toContain("abc");
expect(getByTestId("peer-list-row-abc").textContent).not.toContain("…");
});
it("applies user style override", () => {
const { getByTestId } = renderComponent(
<PeerList
title="x"
peers={[]}
onSelect={() => undefined}
style={{ flex: 2 }}
/>
);
const node = getByTestId("peer-list") as HTMLElement;
expect(node.style.flex.startsWith("2")).toBe(true);
});
});

View File

@@ -0,0 +1,104 @@
import type { CSSProperties, ReactElement } from "react";
import type { PeerInfo } from "../../types/peer-info.types.js";
import type { PeerListProps } from "./peer-list.types.js";
const CONTAINER_STYLE: CSSProperties = {
maxHeight: 200,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 4,
backgroundColor: "#f8f9fa",
};
const BUTTON_BASE: CSSProperties = {
width: "100%",
textAlign: "left",
padding: 8,
border: "none",
cursor: "pointer",
};
function truncate(id: string): string {
return id.length > 20 ? `${id.substring(0, 20)}…` : id;
}
function PeerRow({
peer,
last,
onSelect,
highlight,
}: {
readonly peer: PeerInfo;
readonly last: boolean;
readonly onSelect: (id: string) => void;
readonly highlight: boolean;
}): ReactElement {
const bg = highlight && peer.connected ? "#e7f5e7" : "transparent";
return (
<button
type="button"
data-testid={`peer-list-row-${peer.id}`}
onClick={() => onSelect(peer.id)}
style={{
...BUTTON_BASE,
borderBottom: last ? "none" : "1px solid #ddd",
backgroundColor: bg,
}}
>
<div style={{ fontSize: 12, fontWeight: "bold" }}>
{truncate(peer.id)}
{peer.connected && (
<span style={{ color: "green", marginLeft: 10 }}>●</span>
)}
</div>
<div style={{ fontSize: 10, color: "#666" }}>
{peer.protocols.length} protocols, {peer.addresses.length}{" "}
addresses
</div>
</button>
);
}
/**
* Reusable peer list. Renders either discovered or connected peers — the
* caller supplies the title, empty message, and optional highlight rule.
* Selecting a row invokes `onSelect(peerId)` with the full (untruncated)
* peer id.
*/
export function PeerList(props: PeerListProps): ReactElement {
const {
title,
peers,
emptyMessage = "No peers",
onSelect,
highlightConnected = true,
style,
} = props;
return (
<div data-testid="peer-list" style={style ?? { flex: 1 }}>
<h3 data-testid="peer-list-title">
{title} ({peers.length})
</h3>
<div style={CONTAINER_STYLE}>
{peers.length === 0 ? (
<div
data-testid="peer-list-empty"
style={{ padding: 10, color: "#666" }}
>
{emptyMessage}
</div>
) : (
peers.map((peer, index) => (
<PeerRow
key={peer.id}
peer={peer}
last={index === peers.length - 1}
onSelect={onSelect}
highlight={highlightConnected}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { CSSProperties } from "react";
import type { PeerInfo } from "../../types/peer-info.types.js";
export interface PeerListProps {
readonly title: string;
readonly peers: readonly PeerInfo[];
readonly emptyMessage?: string;
readonly onSelect: (peerId: string) => void;
readonly highlightConnected?: boolean;
readonly style?: CSSProperties;
}

View File

@@ -0,0 +1,88 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `REACT_LIBP2P_DEMO` error-code registry and `ReactLibp2pDemoError`
class used across the `@suaveplan/react-network-libp2p-demo` package. Every
public surface that fails with a domain-level reason throws a typed
`ReactLibp2pDemoError` rather than a bare `Error`, allowing callers to
discriminate on the stable `code` literal at the boundary without resorting to
message-string matching or `instanceof` chains. The package is a demo UI for
`@suaveplan/network-transport-libp2p`, so the registry covers user-facing
options validation plus the small set of runtime failure modes the demo
surfaces through its log panel.
## Features
- Frozen `REACT_LIBP2P_DEMO_CODES` literal union via `defineCodes()` from
`@suaveplan/error`.
- 5 canonical failure classes: `INVALID_OPTIONS`, `DEMO_DISPOSED`,
`NODE_NOT_STARTED`, `DIAL_FAILED`, `ECHO_FAILED`.
- Type-narrowed `ReactLibp2pDemoErrorCode` for switch-exhaustiveness in
consumer code.
- Class identity preserved via explicit `this.name = "ReactLibp2pDemoError"`.
- ES2022 `cause` chain plumbed through the `SuaveplanErrorOptions` argument so
the underlying libp2p / Zod / transport error stays attached.
## Usage: Basic Example
```ts
import {
REACT_LIBP2P_DEMO_CODES,
ReactLibp2pDemoError,
} from "@suaveplan/react-network-libp2p-demo";
throw new ReactLibp2pDemoError(
REACT_LIBP2P_DEMO_CODES.INVALID_OPTIONS,
"react_libp2p_demo.options.invalid",
);
```
## Usage: Advanced Example
```ts
import {
REACT_LIBP2P_DEMO_CODES,
ReactLibp2pDemoError,
} from "@suaveplan/react-network-libp2p-demo";
function handle(err: unknown): void {
if (err instanceof ReactLibp2pDemoError) {
switch (err.code) {
case REACT_LIBP2P_DEMO_CODES.NODE_NOT_STARTED:
ui.flashWarning("Click Start Node first.");
return;
case REACT_LIBP2P_DEMO_CODES.DIAL_FAILED:
ui.flashError("Dial failed — check the multiaddr.");
return;
default:
ui.flashError(err.code);
}
}
throw err;
}
```
## API Reference
- `REACT_LIBP2P_DEMO_CODES` — frozen record literal of the 5 codes.
- `ReactLibp2pDemoErrorCode` — discriminated string-literal union over the
values.
- `ReactLibp2pDemoError extends SuaveplanError<ReactLibp2pDemoErrorCode>` —
domain error class. Constructor `(code, messageKey, options?)`.
## Implementation Notes
Errors are translation-key-driven (`messageKey`) so user-visible strings can
be resolved through `@suaveplan/i18n` at the boundary. The registry is short
by design — every failure category should map to an actionable demo response
(retry, validate input, restart node). The class is exported alongside the
codes object so callers can both narrow on the `instanceof ReactLibp2pDemoError`
discriminant AND switch on the `code` literal; combining both gives the
strongest type-narrowing in a single check. Avoid catching the base
`SuaveplanError` class — always catch `ReactLibp2pDemoError` specifically so
cross-package error confusion never surfaces as silent recovery in the demo
UI's log feed.

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_LIBP2P_DEMO_CODES, ReactLibp2pDemoError } from "./errors.js";
describe("ReactLibp2pDemoError", () => {
it("preserves code, name, and message-key", () => {
const err = new ReactLibp2pDemoError(
REACT_LIBP2P_DEMO_CODES.INVALID_OPTIONS,
"react_libp2p_demo.test.invalid"
);
expect(err.code).toBe(REACT_LIBP2P_DEMO_CODES.INVALID_OPTIONS);
expect(err.name).toBe("ReactLibp2pDemoError");
});
it("registers all 5 codes", () => {
const codes = Object.values(REACT_LIBP2P_DEMO_CODES);
expect(codes.length).toBe(5);
expect(codes).toContain("REACT_LIBP2P_DEMO.INVALID_OPTIONS");
expect(codes).toContain("REACT_LIBP2P_DEMO.DEMO_DISPOSED");
expect(codes).toContain("REACT_LIBP2P_DEMO.NODE_NOT_STARTED");
expect(codes).toContain("REACT_LIBP2P_DEMO.DIAL_FAILED");
expect(codes).toContain("REACT_LIBP2P_DEMO.ECHO_FAILED");
});
it("preserves the cause via SuaveplanErrorOptions", () => {
const cause = new Error("underlying");
const err = new ReactLibp2pDemoError(
REACT_LIBP2P_DEMO_CODES.DIAL_FAILED,
"react_libp2p_demo.test.dial",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,27 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const REACT_LIBP2P_DEMO_CODES = defineCodes("REACT_LIBP2P_DEMO", [
"INVALID_OPTIONS",
"DEMO_DISPOSED",
"NODE_NOT_STARTED",
"DIAL_FAILED",
"ECHO_FAILED",
] as const);
export type ReactLibp2pDemoErrorCode =
(typeof REACT_LIBP2P_DEMO_CODES)[keyof typeof REACT_LIBP2P_DEMO_CODES];
export class ReactLibp2pDemoError extends SuaveplanError<ReactLibp2pDemoErrorCode> {
public constructor(
code: ReactLibp2pDemoErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "ReactLibp2pDemoError";
}
}

View File

@@ -0,0 +1,5 @@
export type { ReactLibp2pDemoErrorCode } from "./errors.js";
export {
REACT_LIBP2P_DEMO_CODES,
ReactLibp2pDemoError,
} from "./errors.js";

View File

@@ -0,0 +1,13 @@
export { useLibp2pDemo } from "./use-libp2p-demo.js";
export { UseLibp2pDemoOptionsSchema } from "./use-libp2p-demo.schemas.js";
export type {
DemoPeerEvent,
DemoSubscription,
Libp2pDemoNode,
Libp2pDemoNodeConfig,
Libp2pDemoNodeFactory,
UseLibp2pDemoOptions,
UseLibp2pDemoResult,
} from "./use-libp2p-demo.types.js";
export type { WirePeerEventsArgs } from "./wire-peer-events.js";
export { wirePeerEvents } from "./wire-peer-events.js";

View File

@@ -0,0 +1,116 @@
# `use-libp2p-demo`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- wire-peer-events — Side-effect helper that subscrib…
- [wire-peer-events](./wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
- [wire-peer-events](./wire-peer-events.md) — Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events (`peer:discovery`, `peer:connect`, `peer:d…
## Purpose
State + lifecycle React hook that drives the libp2p demo. Owns the
underlying `Libp2pDemoNode` ref, wires peer-event subscriptions through
`wirePeerEvents`, maintains the discovered + connected peer lists, runs the
append-only log ring, and exposes start / stop / dial / ping / echo /
clearLogs callbacks. The hook is the only stateful piece of the package;
every component below it is presentational and consumes either the result
shape directly or pulled-out slices of it.
## Features
- Validates options through `UseLibp2pDemoOptionsSchema` (Zod) on every
render call; failed validation raises a typed `ReactLibp2pDemoError`
(`INVALID_OPTIONS`).
- Generates monotonic ids for log entries via an internal ref counter so
React's list-reconciler never warns about duplicate keys during fast
event bursts.
- Caps log retention at `maxLogEntries` (default 100) using an
always-trim-then-append ring; reads stay O(1) per append.
- Injectable `clock` (defaults to `Date.now`) for deterministic tests.
- AbortSignal threaded through every async action (`dial`, `ping`, `echo`).
- Idempotent `start` (silently returns when a node already exists) and
`stop` (silently returns when none does).
- Cleans up subscriptions + stops the node on unmount.
## Usage: Basic Example
```tsx
import { useLibp2pDemo } from "@suaveplan/react-network-libp2p-demo";
function MyDemo({ nodeFactory }: { nodeFactory: Libp2pDemoNodeFactory }) {
const demo = useLibp2pDemo({
nodeFactory,
bootstrap: [],
});
return <button type="button" onClick={() => void demo.start()}>Start</button>;
}
```
## Usage: Advanced Example
```tsx
import { useLibp2pDemo } from "@suaveplan/react-network-libp2p-demo";
function FullDemo({ nodeFactory }: { nodeFactory: Libp2pDemoNodeFactory }) {
const demo = useLibp2pDemo({
nodeFactory,
bootstrap: ["/dnsaddr/example/p2p/Qm…"],
subTransport: "webrtc",
listen: ["/webrtc"],
autoStart: true,
clock: () => performance.now(),
maxLogEntries: 250,
});
return (
<>
<p>Peer id: {demo.peerId}</p>
<p>Connected: {demo.connectedPeers.length}</p>
<ul>
{demo.logs.map((l) => (
<li key={l.id}>{l.message}</li>
))}
</ul>
</>
);
}
```
## API Reference
- `UseLibp2pDemoOptions` — `{ nodeFactory: Libp2pDemoNodeFactory;
bootstrap: readonly string[]; subTransport?; listen?; maxConnections?;
dialTimeoutMs?; protocolId?; autoStart?; maxLogEntries?; clock? }`.
- `UseLibp2pDemoResult` — `{ isStarted; peerId; listenAddresses;
discoveredPeers; connectedPeers; logs; lastPingMs; start; stop;
clearLogs; dial; ping; echo; appendLog }`.
- `useLibp2pDemo(options)` — throws `ReactLibp2pDemoError(INVALID_OPTIONS)`
on bad input.
## Implementation Notes
The hook accepts a `Libp2pDemoNodeFactory` (structural — no libp2p type
leaks into the React tree). Production consumers wrap the real libp2p
stack inside the factory; tests pass a deterministic fake driving the
same `Libp2pDemoNode` contract. Auto-start fires once on mount and is
intentionally not retriggered when options change — repeatedly tearing
down + recreating a libp2p node on every prop tweak would make the demo
unusable; consumers can drive lifecycle explicitly through the `start` /
`stop` callbacks. The `_demoCleanup` field stashed on the node instance
holds the wire-events unsubscribe closure so `stop` can call it once
without owning the subscription identities itself. The log ring uses an
`Array.prototype.slice(-(max-1))` trim instead of `Array.splice` to keep
state updates immutable (React's reconciler relies on identity changes).
Disposal flips a ref flag so any in-flight async actions racing the
unmount no longer append logs to a state slot React has already
discarded.

View File

@@ -0,0 +1,29 @@
import z from "zod";
/**
* Zod schema gating the public `UseLibp2pDemoOptions` boundary. The
* `nodeFactory` and `clock` callable fields are validated structurally
* (presence + `typeof === "function"`) — Zod cannot verify the inner
* signatures and we never want to wrap user callbacks.
*/
export const UseLibp2pDemoOptionsSchema = z
.object({
nodeFactory: z.custom<(c: unknown) => unknown>(
(v) => typeof v === "function",
{ message: "nodeFactory must be a function" }
),
bootstrap: z.array(z.string()).readonly(),
subTransport: z.enum(["websockets", "webrtc"]).optional(),
listen: z.array(z.string()).readonly().optional(),
maxConnections: z.number().int().positive().optional(),
dialTimeoutMs: z.number().int().positive().optional(),
protocolId: z.string().min(1).optional(),
autoStart: z.boolean().optional(),
maxLogEntries: z.number().int().positive().optional(),
clock: z
.custom<() => number>((v) => typeof v === "function", {
message: "clock must be a function",
})
.optional(),
})
.strict();

View File

@@ -0,0 +1,493 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { type ReactElement, useEffect } from "react";
import {
createFakeNode,
type FakeNode,
fakeNodeFactory,
} from "../../../tests/setup/fake-node.js";
import { ReactLibp2pDemoError } from "../../errors/errors.js";
import { useLibp2pDemo } from "./use-libp2p-demo.js";
import type {
Libp2pDemoNodeFactory,
UseLibp2pDemoOptions,
UseLibp2pDemoResult,
} from "./use-libp2p-demo.types.js";
interface HarnessProps {
readonly options: UseLibp2pDemoOptions;
readonly capture: (result: UseLibp2pDemoResult) => void;
readonly onMount?: (result: UseLibp2pDemoResult) => void;
}
function Harness({ options, capture, onMount }: HarnessProps): ReactElement {
const result = useLibp2pDemo(options);
capture(result);
useEffect(() => {
onMount?.(result);
}, [result, onMount]);
return <div data-testid="harness">{result.peerId}</div>;
}
function setup(
options: Partial<UseLibp2pDemoOptions> & {
readonly nodeFactory: Libp2pDemoNodeFactory;
},
onMount?: (result: UseLibp2pDemoResult) => void
): {
readonly getResult: () => UseLibp2pDemoResult;
readonly unmount: () => void;
readonly rerender: () => void;
} {
let last: UseLibp2pDemoResult | null = null;
const r = renderComponent(
<Harness
options={{ bootstrap: [], ...options }}
capture={(res) => {
last = res;
}}
onMount={onMount}
/>
);
return {
getResult: () => {
if (!last) throw new Error("hook not captured");
return last;
},
unmount: () => r.unmount(),
rerender: () =>
r.rerender(
<Harness
options={{ bootstrap: [], ...options }}
capture={(res) => {
last = res;
}}
/>
),
};
}
describe("useLibp2pDemo", () => {
it("validates options via Zod and throws on bad input", () => {
let thrown: unknown = null;
try {
renderComponent(
<Harness
options={
// @ts-expect-error intentionally bad
{ bootstrap: 0 }
}
capture={() => undefined}
/>
);
} catch (err) {
thrown = err;
}
expect(thrown).not.toBeNull();
// `renderComponent` wraps the inner error in `RenderError`; the
// hook's own error is preserved as `cause`.
const cause = (thrown as { cause?: unknown }).cause;
expect(cause).toBeInstanceOf(ReactLibp2pDemoError);
});
it("starts and stops the node", async () => {
const node = createFakeNode({ peerId: "12D3KooWAlice" });
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
});
expect(getResult().isStarted).toBe(true);
expect(getResult().peerId).toBe("12D3KooWAlice");
expect(node.startedCount()).toBe(1);
await act(async () => {
await getResult().stop();
});
expect(getResult().isStarted).toBe(false);
expect(node.stoppedCount()).toBe(1);
});
it("logs and aborts start when node factory throws", async () => {
const factory: Libp2pDemoNodeFactory = async () => {
throw new Error("boom");
};
const { getResult } = setup({ nodeFactory: factory });
await act(async () => {
await getResult().start();
});
expect(getResult().isStarted).toBe(false);
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("boom");
});
it("ignores second start() while already started", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
await getResult().start();
});
expect(node.startedCount()).toBe(1);
});
it("stop() is a noop when no node exists", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().stop();
});
expect(node.stoppedCount()).toBe(0);
});
it("logs error when node.start() rejects", async () => {
const node = createFakeNode({ throwOnStart: new Error("nope") });
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("nope");
});
it("logs error when node.stop() rejects", async () => {
const node = createFakeNode({ throwOnStop: new Error("badstop") });
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
await getResult().stop();
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("badstop");
});
it("propagates discovery / connect / disconnect events into state", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
});
await act(async () => {
node.emitDiscovery({ peerId: "P1", addresses: ["/a"] });
node.emitDiscovery({ peerId: "P1" }); // duplicate ignored
node.emitConnect({ peerId: "P1" });
node.emitConnect({ peerId: "P1" }); // duplicate ignored
});
expect(getResult().discoveredPeers.length).toBe(1);
expect(getResult().connectedPeers.length).toBe(1);
expect(getResult().discoveredPeers[0]?.connected).toBe(true);
await act(async () => {
node.emitDisconnect({ peerId: "P1" });
});
expect(getResult().connectedPeers.length).toBe(0);
expect(getResult().discoveredPeers[0]?.connected).toBe(false);
});
it("dial / ping / echo append success logs and ping updates lastPingMs", async () => {
const node = createFakeNode({
dialResult: "remote1",
pingResult: 17,
echoResult: "echoed",
});
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
await getResult().dial("/a");
await getResult().ping("12D3");
await getResult().echo("12D3", "hi");
});
const successes = getResult().logs.filter((l) => l.level === "success");
expect(successes.map((s) => s.message)).toEqual(
expect.arrayContaining([
"Dialed: remote1",
"Ping successful: 17ms",
"Echo response: echoed",
])
);
expect(getResult().lastPingMs).toBe(17);
});
it("dial / ping / echo log error when node not started", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().dial("/a");
await getResult().ping("p");
await getResult().echo("p", "x");
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.length).toBe(3);
expect(errors.every((e) => e.message === "Node not started")).toBe(
true
);
});
it("dial / ping / echo log error when underlying call rejects", async () => {
const node = createFakeNode({
throwOnDial: new Error("dialfail"),
throwOnPing: new Error("pingfail"),
throwOnEcho: new Error("echofail"),
});
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
await getResult().dial("/a");
await getResult().ping("p");
await getResult().echo("p", "x");
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.some((e) => e.message.includes("dialfail"))).toBe(true);
expect(errors.some((e) => e.message.includes("pingfail"))).toBe(true);
expect(errors.some((e) => e.message.includes("echofail"))).toBe(true);
});
it("dial / ping / echo handle non-Error rejections", async () => {
const factory = fakeNodeFactory(createFakeNode());
const { getResult } = setup({ nodeFactory: factory });
const sentinel: unknown = "string-rejection";
// Replace the node's dial after start to simulate non-Error throw.
await act(async () => {
await getResult().start();
});
// Reach in via the live ref by re-rendering a new node that throws
// a non-Error rejection through the factory closure: easier to
// call dial against a freshly built error.
const factory2 = fakeNodeFactory(
createFakeNode({ throwOnDial: sentinel as Error })
);
const second = setup({ nodeFactory: factory2 });
await act(async () => {
await second.getResult().start();
await second.getResult().dial("/a");
});
const errors = second
.getResult()
.logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("string-rejection");
});
it("dial / ping / echo accept an AbortSignal and forward it", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
const controller = new AbortController();
await act(async () => {
await getResult().start();
await getResult().dial("/a", controller.signal);
await getResult().ping("p", controller.signal);
await getResult().echo("p", "m", controller.signal);
});
const successes = getResult().logs.filter((l) => l.level === "success");
// Three calls completed without throwing — abort path not exercised.
expect(successes.length).toBeGreaterThanOrEqual(3);
});
it("ping / echo handle non-Error rejections", async () => {
const sentinel: unknown = "ping-sentinel";
const pingNode = createFakeNode({ throwOnPing: sentinel as Error });
const pingSetup = setup({ nodeFactory: fakeNodeFactory(pingNode) });
await act(async () => {
await pingSetup.getResult().start();
await pingSetup.getResult().ping("p");
});
const pingErrors = pingSetup
.getResult()
.logs.filter((l) => l.level === "error");
expect(pingErrors.at(-1)?.message).toContain("ping-sentinel");
const echoSentinel: unknown = "echo-sentinel";
const echoNode = createFakeNode({
throwOnEcho: echoSentinel as Error,
});
const echoSetup = setup({ nodeFactory: fakeNodeFactory(echoNode) });
await act(async () => {
await echoSetup.getResult().start();
await echoSetup.getResult().echo("p", "m");
});
const echoErrors = echoSetup
.getResult()
.logs.filter((l) => l.level === "error");
expect(echoErrors.at(-1)?.message).toContain("echo-sentinel");
});
it("start handles non-Error factory rejection", async () => {
const factory: Libp2pDemoNodeFactory = async () => {
// biome shaped as Error via cast; runtime value is a string.
throw "boomstring" as unknown as Error;
};
const { getResult } = setup({ nodeFactory: factory });
await act(async () => {
await getResult().start();
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("boomstring");
});
it("stop handles non-Error rejection", async () => {
const sentinel: unknown = "stop-sentinel";
const node = createFakeNode({ throwOnStop: sentinel as Error });
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
await getResult().stop();
});
const errors = getResult().logs.filter((l) => l.level === "error");
expect(errors.at(-1)?.message).toContain("stop-sentinel");
});
it("clearLogs empties the log buffer", async () => {
const node = createFakeNode();
const { getResult } = setup({ nodeFactory: fakeNodeFactory(node) });
await act(async () => {
await getResult().start();
});
expect(getResult().logs.length).toBeGreaterThan(0);
await act(() => {
getResult().clearLogs();
});
expect(getResult().logs.length).toBe(0);
});
it("appendLog enforces the maxLogEntries ring", async () => {
let now = 0;
const node = createFakeNode();
const { getResult } = setup({
nodeFactory: fakeNodeFactory(node),
maxLogEntries: 3,
clock: () => {
now += 1;
return now;
},
});
await act(() => {
for (let i = 0; i < 5; i += 1) {
getResult().appendLog("info", `m${i}`);
}
});
const logs = getResult().logs;
expect(logs.length).toBe(3);
expect(logs.at(-1)?.message).toBe("m4");
});
it("autoStart triggers start on mount", async () => {
const node = createFakeNode();
let captured: UseLibp2pDemoResult | null = null;
await act(async () => {
renderComponent(
<Harness
options={{
nodeFactory: fakeNodeFactory(node),
bootstrap: [],
autoStart: true,
}}
capture={(r) => {
captured = r;
}}
/>
);
});
// Allow the async start chain to settle.
await act(async () => {
await Promise.resolve();
});
expect(node.startedCount()).toBe(1);
expect(captured?.isStarted ?? false).toBe(true);
});
it("stops the underlying node on unmount", async () => {
const node = createFakeNode();
let result: UseLibp2pDemoResult | null = null;
const r = renderComponent(
<Harness
options={{
nodeFactory: fakeNodeFactory(node),
bootstrap: [],
}}
capture={(res) => {
result = res;
}}
/>
);
await act(async () => {
await result!.start();
});
expect(node.startedCount()).toBe(1);
r.unmount();
await act(async () => {
await Promise.resolve();
});
expect(node.stoppedCount()).toBe(1);
});
it("ignores start() after unmount (disposed guard)", async () => {
const node = createFakeNode();
const { getResult, unmount } = setup({
nodeFactory: fakeNodeFactory(node),
});
const captured = getResult();
unmount();
await act(async () => {
await captured.start();
});
expect(node.startedCount()).toBe(0);
});
it("ignores in-flight log appends after unmount", async () => {
const node = createFakeNode();
let result: UseLibp2pDemoResult | null = null;
const r = renderComponent(
<Harness
options={{
nodeFactory: fakeNodeFactory(node),
bootstrap: [],
}}
capture={(res) => {
result = res;
}}
/>
);
const captured = result!;
r.unmount();
// After unmount the disposedRef flag should prevent further appends.
await act(() => {
captured.appendLog("info", "after-unmount");
});
// We cannot read logs through the unmounted instance; assert we did
// not throw and the call was a no-op.
expect(true).toBe(true);
});
it("supplies sane defaults when optional fields omitted", async () => {
const node = createFakeNode();
await act(async () => {
const r = renderComponent(
<Harness
options={{
nodeFactory: fakeNodeFactory(node),
bootstrap: [],
}}
capture={() => undefined}
/>
);
r.unmount();
});
// If no throw, the defaults path is exercised.
expect(node.startedCount()).toBe(0);
});
it("survives a no-op cleanup when node is already null", async () => {
const node = createFakeNode();
const { getResult, unmount } = setup({
nodeFactory: fakeNodeFactory(node),
});
await act(async () => {
await getResult().start();
await getResult().stop();
});
unmount();
// Cleanup pathway with nodeRef.current === null exercised.
expect(node.stoppedCount()).toBe(1);
});
});
// Silence unused import warning when tests do not name FakeNode but the
// type re-export is needed for fakeNodeFactory typing in some calls.
export type { FakeNode };

View File

@@ -0,0 +1,263 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
REACT_LIBP2P_DEMO_CODES,
ReactLibp2pDemoError,
} from "../../errors/errors.js";
import type { LogEntry, LogLevel } from "../../types/log-entry.types.js";
import type { PeerInfo } from "../../types/peer-info.types.js";
import { UseLibp2pDemoOptionsSchema } from "./use-libp2p-demo.schemas.js";
import type {
Libp2pDemoNode,
UseLibp2pDemoOptions,
UseLibp2pDemoResult,
} from "./use-libp2p-demo.types.js";
import { wirePeerEvents } from "./wire-peer-events.js";
const DEFAULT_PROTOCOL_ID = "/suaveplan/demo/echo/1.0.0";
const DEFAULT_LISTEN: readonly string[] = ["/webrtc", "/p2p-circuit"];
const DEFAULT_MAX_CONNECTIONS = 64;
const DEFAULT_DIAL_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_LOG_ENTRIES = 100;
const defaultClock = (): number => Date.now();
interface NodeCleanupHolder {
_demoCleanup?: () => void;
}
/**
* State + lifecycle hook driving the React libp2p demo. Owns the
* underlying `Libp2pDemoNode` ref, peer-event subscriptions, and the
* append-only log ring. Returns a presentation-friendly result object
* consumed by the demo's subcomponents.
*/
export function useLibp2pDemo(
options: UseLibp2pDemoOptions
): UseLibp2pDemoResult {
const parsed = UseLibp2pDemoOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ReactLibp2pDemoError(
REACT_LIBP2P_DEMO_CODES.INVALID_OPTIONS,
"react_libp2p_demo.options.invalid",
{ cause: parsed.error }
);
}
const {
nodeFactory,
bootstrap,
subTransport = "websockets",
listen = DEFAULT_LISTEN,
maxConnections = DEFAULT_MAX_CONNECTIONS,
dialTimeoutMs = DEFAULT_DIAL_TIMEOUT_MS,
protocolId = DEFAULT_PROTOCOL_ID,
autoStart = false,
maxLogEntries = DEFAULT_MAX_LOG_ENTRIES,
clock = defaultClock,
} = options;
const [isStarted, setIsStarted] = useState(false);
const [peerId, setPeerId] = useState("");
const [listenAddresses, setListenAddresses] = useState<readonly string[]>(
[]
);
const [discoveredPeers, setDiscoveredPeers] = useState<readonly PeerInfo[]>(
[]
);
const [connectedPeers, setConnectedPeers] = useState<readonly PeerInfo[]>(
[]
);
const [logs, setLogs] = useState<readonly LogEntry[]>([]);
const [lastPingMs, setLastPingMs] = useState<number | null>(null);
const nodeRef = useRef<Libp2pDemoNode | null>(null);
const logIdRef = useRef(0);
const disposedRef = useRef(false);
const appendLog = useCallback(
(level: LogLevel, message: string): void => {
if (disposedRef.current) return;
logIdRef.current += 1;
const id = `${logIdRef.current}`;
setLogs((prev) => {
const next = prev.slice(-(maxLogEntries - 1));
return [...next, { id, timestamp: clock(), level, message }];
});
},
[clock, maxLogEntries]
);
const start = useCallback(async (): Promise<void> => {
if (disposedRef.current) return;
if (nodeRef.current) return;
appendLog("info", "Creating libp2p node…");
try {
const node = await nodeFactory({
bootstrap,
subTransport,
listen,
maxConnections,
dialTimeoutMs,
protocolId,
});
const cleanup = wirePeerEvents({
node,
setDiscoveredPeers,
setConnectedPeers,
appendLog,
});
(node as NodeCleanupHolder)._demoCleanup = cleanup;
await node.start();
nodeRef.current = node;
setIsStarted(true);
setPeerId(node.peerId);
setListenAddresses(node.listenAddresses);
appendLog("success", `Node started with id: ${node.peerId}`);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
appendLog("error", `Failed to start node: ${message}`);
}
}, [
appendLog,
bootstrap,
dialTimeoutMs,
listen,
maxConnections,
nodeFactory,
protocolId,
subTransport,
]);
const stop = useCallback(async (): Promise<void> => {
const node = nodeRef.current;
if (!node) return;
appendLog("info", "Stopping libp2p node…");
try {
(node as NodeCleanupHolder)._demoCleanup?.();
await node.stop();
nodeRef.current = null;
setIsStarted(false);
setPeerId("");
setListenAddresses([]);
setDiscoveredPeers([]);
setConnectedPeers([]);
appendLog("success", "Node stopped");
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
appendLog("error", `Failed to stop node: ${message}`);
}
}, [appendLog]);
const clearLogs = useCallback((): void => {
setLogs([]);
}, []);
const dial = useCallback(
async (address: string, signal?: AbortSignal): Promise<void> => {
const node = nodeRef.current;
if (!node) {
appendLog("error", "Node not started");
return;
}
appendLog("info", `Dialing peer: ${address}`);
try {
const remote = await node.dial(
address,
signal ? { signal } : undefined
);
appendLog("success", `Dialed: ${remote}`);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
appendLog("error", `Dial failed: ${message}`);
}
},
[appendLog]
);
const ping = useCallback(
async (target: string, signal?: AbortSignal): Promise<void> => {
const node = nodeRef.current;
if (!node) {
appendLog("error", "Node not started");
return;
}
appendLog("info", `Pinging peer: ${target}`);
try {
const latency = await node.ping(
target,
signal ? { signal } : undefined
);
setLastPingMs(latency);
appendLog("success", `Ping successful: ${latency}ms`);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
appendLog("error", `Ping failed: ${message}`);
}
},
[appendLog]
);
const echo = useCallback(
async (
target: string,
message: string,
signal?: AbortSignal
): Promise<void> => {
const node = nodeRef.current;
if (!node) {
appendLog("error", "Node not started");
return;
}
appendLog("info", `Sending echo to: ${target}`);
try {
const response = await node.echo(
target,
message,
signal ? { signal } : undefined
);
appendLog("success", `Echo response: ${response}`);
} catch (error) {
const reason =
error instanceof Error ? error.message : String(error);
appendLog("error", `Echo failed: ${reason}`);
}
},
[appendLog]
);
useEffect(() => {
if (autoStart) {
void start();
}
return (): void => {
disposedRef.current = true;
const node = nodeRef.current;
if (node) {
(node as NodeCleanupHolder)._demoCleanup?.();
void node.stop().catch((): undefined => undefined);
nodeRef.current = null;
}
};
}, [autoStart, start]);
return {
isStarted,
peerId,
listenAddresses,
discoveredPeers,
connectedPeers,
logs,
lastPingMs,
start,
stop,
clearLogs,
dial,
ping,
echo,
appendLog,
};
}

View File

@@ -0,0 +1,108 @@
import type { LogEntry, LogLevel } from "../../types/log-entry.types.js";
import type { PeerInfo } from "../../types/peer-info.types.js";
/**
* Discovered/connected/disconnected peer-event detail. The string form
* of the peer id is what the demo retains.
*/
export interface DemoPeerEvent {
readonly peerId: string;
readonly addresses?: readonly string[];
}
/**
* Subscription handle returned by `Libp2pDemoNode.subscribe`. Cleanup is
* idempotent.
*/
export interface DemoSubscription {
readonly unsubscribe: () => void;
}
/**
* Structural interface representing the libp2p surface this demo
* consumes. The package never imports libp2p directly — the caller
* supplies an adapter that wraps a real `Libp2p` (or a fake in tests).
*
* Mirrors the small subset of the libp2p API exercised by the upstream
* 749-LOC demo: lifecycle, peer events, dial, ping, and an echo round-trip.
*/
export interface Libp2pDemoNode {
readonly peerId: string;
readonly listenAddresses: readonly string[];
start(): Promise<void>;
stop(): Promise<void>;
dial(address: string, options?: { signal?: AbortSignal }): Promise<string>;
ping(peerId: string, options?: { signal?: AbortSignal }): Promise<number>;
echo(
peerAddress: string,
message: string,
options?: { signal?: AbortSignal }
): Promise<string>;
onPeerDiscovery(handler: (evt: DemoPeerEvent) => void): DemoSubscription;
onPeerConnect(handler: (evt: DemoPeerEvent) => void): DemoSubscription;
onPeerDisconnect(handler: (evt: DemoPeerEvent) => void): DemoSubscription;
}
/**
* Factory producing a `Libp2pDemoNode` from the demo's configuration.
* Production consumers wire this to a real libp2p stack; tests pass a
* deterministic fake.
*/
export type Libp2pDemoNodeFactory = (
config: Libp2pDemoNodeConfig
) => Promise<Libp2pDemoNode>;
/**
* Configuration forwarded to the demo-node factory.
*/
export interface Libp2pDemoNodeConfig {
readonly bootstrap: readonly string[];
readonly subTransport: "websockets" | "webrtc";
readonly listen: readonly string[];
readonly maxConnections: number;
readonly dialTimeoutMs: number;
readonly protocolId: string;
}
/**
* Options accepted by the demo hook.
*/
export interface UseLibp2pDemoOptions {
/** REQUIRED libp2p demo-node factory. No public default. */
readonly nodeFactory: Libp2pDemoNodeFactory;
/**
* REQUIRED bootstrap multiaddr list. Empty array is permitted
* (mDNS-only / WebRTC-only) but the caller MUST supply it
* explicitly — the demo publishes no public defaults.
*/
readonly bootstrap: readonly string[];
readonly subTransport?: "websockets" | "webrtc";
readonly listen?: readonly string[];
readonly maxConnections?: number;
readonly dialTimeoutMs?: number;
readonly protocolId?: string;
readonly autoStart?: boolean;
readonly maxLogEntries?: number;
readonly clock?: () => number;
}
export interface UseLibp2pDemoResult {
readonly isStarted: boolean;
readonly peerId: string;
readonly listenAddresses: readonly string[];
readonly discoveredPeers: readonly PeerInfo[];
readonly connectedPeers: readonly PeerInfo[];
readonly logs: readonly LogEntry[];
readonly lastPingMs: number | null;
readonly start: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly clearLogs: () => void;
readonly dial: (address: string, signal?: AbortSignal) => Promise<void>;
readonly ping: (peerId: string, signal?: AbortSignal) => Promise<void>;
readonly echo: (
peerAddress: string,
message: string,
signal?: AbortSignal
) => Promise<void>;
readonly appendLog: (level: LogLevel, message: string) => void;
}

View File

@@ -0,0 +1,74 @@
# `wire-peer-events`
## Purpose
Side-effect helper that subscribes to a `Libp2pDemoNode`'s three peer events
(`peer:discovery`, `peer:connect`, `peer:disconnect`) and translates them into
React state updates on the discovered + connected peer lists plus log-panel
entries. Lives outside the main `useLibp2pDemo` hook file so the hook stays
under the package's 300-LOC source-file cap.
## Features
- Three handlers wired to the node's typed event subscription API.
- Idempotent: connect/discovery events ignore duplicates by peer id.
- Updates both lists on connect — a peer can be present in `discovered`
before its `connect` event fires, and that record needs its `connected`
flag flipped.
- Returns a single cleanup callback that unsubscribes all three handlers
in one call.
- Pure data flow into the supplied setters + log appender — no DOM, no
refs, no timers.
## Usage: Basic Example
```ts
import { wirePeerEvents } from "@suaveplan/react-network-libp2p-demo";
const cleanup = wirePeerEvents({
node,
setDiscoveredPeers,
setConnectedPeers,
appendLog,
});
// later:
cleanup();
```
## Usage: Advanced Example
```ts
import { wirePeerEvents } from "@suaveplan/react-network-libp2p-demo";
useEffect(() => {
const cleanup = wirePeerEvents({
node: nodeRef.current!,
setDiscoveredPeers,
setConnectedPeers,
appendLog,
});
return cleanup;
}, [appendLog]);
```
## API Reference
- `WirePeerEventsArgs` — `{ node, setDiscoveredPeers, setConnectedPeers,
appendLog }`.
- `wirePeerEvents(args: WirePeerEventsArgs): () => void` — returns the
cleanup function.
## Implementation Notes
Cleanup is fire-and-forget — the returned closure invokes
`unsubscribe()` on every captured `DemoSubscription`, never throws, and is
safe to call multiple times because the underlying subscriptions are
idempotent per the structural contract. The dedupe-by-id check uses
`Array.prototype.some` for clarity over a `Map` cache; in the demo's
typical peer-population sizes (≤ 100) the linear scan is dominated by
React's render cost anyway. We translate the three event types into
`PeerInfo` records directly inside the wiring layer so the rest of the
hook stays free of any libp2p-shaped types. Two-list state (`discovered`
and `connected`) is preserved as in the upstream demo because it lets the
peer-list panels render both ‘seen but not yet connected’ and ‘actively
exchanging traffic’ without bespoke filtering in the rendering layer.

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Dispatch, SetStateAction } from "react";
import { createFakeNode } from "../../../tests/setup/fake-node.js";
import type { LogLevel } from "../../types/log-entry.types.js";
import type { PeerInfo } from "../../types/peer-info.types.js";
import { wirePeerEvents } from "./wire-peer-events.js";
function setFn<T>(): {
readonly current: T;
readonly setter: Dispatch<SetStateAction<T>>;
} {
const ref = { current: [] as unknown as T };
return {
get current() {
return ref.current;
},
setter: (action) => {
if (typeof action === "function") {
ref.current = (action as (prev: T) => T)(ref.current);
} else {
ref.current = action;
}
},
};
}
describe("wirePeerEvents", () => {
it("appends to discovered + connected lists; logs each event", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
const logs: Array<{ level: LogLevel; message: string }> = [];
const cleanup = wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: (level, message) => {
logs.push({ level, message });
},
});
node.emitDiscovery({ peerId: "P", addresses: ["/a"] });
expect(discovered.current.length).toBe(1);
expect(discovered.current[0]?.id).toBe("P");
node.emitConnect({ peerId: "P" });
expect(connected.current.length).toBe(1);
expect(discovered.current[0]?.connected).toBe(true);
node.emitDisconnect({ peerId: "P" });
expect(connected.current.length).toBe(0);
expect(discovered.current[0]?.connected).toBe(false);
expect(logs.map((l) => l.level)).toEqual([
"info",
"success",
"warning",
]);
cleanup();
});
it("dedups duplicate discovery and connect events", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: () => undefined,
});
node.emitDiscovery({ peerId: "P" });
node.emitDiscovery({ peerId: "P" });
node.emitConnect({ peerId: "P" });
node.emitConnect({ peerId: "P" });
expect(discovered.current.length).toBe(1);
expect(connected.current.length).toBe(1);
});
it("cleanup unsubscribes every handler", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
let logCount = 0;
const cleanup = wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: () => {
logCount += 1;
},
});
cleanup();
node.emitDiscovery({ peerId: "P" });
node.emitConnect({ peerId: "P" });
node.emitDisconnect({ peerId: "P" });
expect(logCount).toBe(0);
expect(discovered.current.length).toBe(0);
});
it("handles missing addresses by falling back to empty array", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: () => undefined,
});
node.emitDiscovery({ peerId: "P" });
node.emitConnect({ peerId: "Q" });
expect(discovered.current[0]?.addresses).toEqual([]);
expect(connected.current[0]?.addresses).toEqual([]);
});
it("connect for an unknown discovered peer still appends to connected", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: () => undefined,
});
node.emitConnect({ peerId: "Unknown", addresses: ["/x"] });
expect(connected.current.length).toBe(1);
// No discovered record existed before; the map() over the empty
// list yields an empty list, which is fine.
expect(discovered.current.length).toBe(0);
});
it("disconnect for an unknown peer leaves both lists unchanged", () => {
const node = createFakeNode();
const discovered = setFn<readonly PeerInfo[]>();
const connected = setFn<readonly PeerInfo[]>();
wirePeerEvents({
node,
setDiscoveredPeers: discovered.setter,
setConnectedPeers: connected.setter,
appendLog: () => undefined,
});
node.emitDiscovery({ peerId: "A" });
node.emitDisconnect({ peerId: "B" });
expect(discovered.current.length).toBe(1);
expect(connected.current.length).toBe(0);
});
});

View File

@@ -0,0 +1,82 @@
import type { Dispatch, SetStateAction } from "react";
import type { LogLevel } from "../../types/log-entry.types.js";
import type { PeerInfo } from "../../types/peer-info.types.js";
import type { DemoPeerEvent, Libp2pDemoNode } from "./use-libp2p-demo.types.js";
/**
* Inputs forwarded into `wirePeerEvents`. Three React state setters and a
* log-append callback are all the side-effects the wiring layer needs.
*/
export interface WirePeerEventsArgs {
readonly node: Libp2pDemoNode;
readonly setDiscoveredPeers: Dispatch<SetStateAction<readonly PeerInfo[]>>;
readonly setConnectedPeers: Dispatch<SetStateAction<readonly PeerInfo[]>>;
readonly appendLog: (level: LogLevel, message: string) => void;
}
/**
* Subscribes to the node's three peer events and translates them into
* `PeerInfo` state mutations + log lines. Returns a cleanup function
* that unsubscribes every handler. Pure side-effect wiring — kept out of
* the main hook so the hook stays under the 300-LOC cap.
*/
export function wirePeerEvents(args: WirePeerEventsArgs): () => void {
const { node, setDiscoveredPeers, setConnectedPeers, appendLog } = args;
const onDiscovery = (evt: DemoPeerEvent): void => {
appendLog("info", `Discovered peer: ${evt.peerId}`);
setDiscoveredPeers((prev) => {
if (prev.some((p) => p.id === evt.peerId)) return prev;
return [
...prev,
{
id: evt.peerId,
protocols: [],
addresses: evt.addresses ?? [],
connected: false,
},
];
});
};
const onConnect = (evt: DemoPeerEvent): void => {
appendLog("success", `Connected to peer: ${evt.peerId}`);
setConnectedPeers((prev) => {
if (prev.some((p) => p.id === evt.peerId)) return prev;
return [
...prev,
{
id: evt.peerId,
protocols: [],
addresses: evt.addresses ?? [],
connected: true,
},
];
});
setDiscoveredPeers((prev) =>
prev.map((p) =>
p.id === evt.peerId ? { ...p, connected: true } : p
)
);
};
const onDisconnect = (evt: DemoPeerEvent): void => {
appendLog("warning", `Disconnected from peer: ${evt.peerId}`);
setConnectedPeers((prev) => prev.filter((p) => p.id !== evt.peerId));
setDiscoveredPeers((prev) =>
prev.map((p) =>
p.id === evt.peerId ? { ...p, connected: false } : p
)
);
};
const subs = [
node.onPeerDiscovery(onDiscovery),
node.onPeerConnect(onConnect),
node.onPeerDisconnect(onDisconnect),
];
return (): void => {
for (const s of subs) s.unsubscribe();
};
}

View File

@@ -0,0 +1,37 @@
// Errors
// Components
export { Libp2pDemo } from "./components/libp2p-demo/libp2p-demo.js";
export type { Libp2pDemoProps } from "./components/libp2p-demo/libp2p-demo.types.js";
export { LogPanel } from "./components/log-panel/log-panel.js";
export type { LogPanelProps } from "./components/log-panel/log-panel.types.js";
export { NodeControls } from "./components/node-controls/node-controls.js";
export type { NodeControlsProps } from "./components/node-controls/node-controls.types.js";
export { NodeStatusPanel } from "./components/node-status-panel/node-status-panel.js";
export type { NodeStatusPanelProps } from "./components/node-status-panel/node-status-panel.types.js";
export { PeerInteractionPanel } from "./components/peer-interaction-panel/peer-interaction-panel.js";
export type { PeerInteractionPanelProps } from "./components/peer-interaction-panel/peer-interaction-panel.types.js";
export { PeerList } from "./components/peer-list/peer-list.js";
export type { PeerListProps } from "./components/peer-list/peer-list.types.js";
export {
REACT_LIBP2P_DEMO_CODES,
ReactLibp2pDemoError,
type ReactLibp2pDemoErrorCode,
} from "./errors/errors.js";
// Hook
export { useLibp2pDemo } from "./hooks/use-libp2p-demo/use-libp2p-demo.js";
export { UseLibp2pDemoOptionsSchema } from "./hooks/use-libp2p-demo/use-libp2p-demo.schemas.js";
export type {
DemoPeerEvent,
DemoSubscription,
Libp2pDemoNode,
Libp2pDemoNodeConfig,
Libp2pDemoNodeFactory,
UseLibp2pDemoOptions,
UseLibp2pDemoResult,
} from "./hooks/use-libp2p-demo/use-libp2p-demo.types.js";
export type { WirePeerEventsArgs } from "./hooks/use-libp2p-demo/wire-peer-events.js";
export { wirePeerEvents } from "./hooks/use-libp2p-demo/wire-peer-events.js";
// Shared types
export type { LogEntry, LogLevel } from "./types/log-entry.types.js";
export type { PeerInfo } from "./types/peer-info.types.js";

View File

@@ -0,0 +1,2 @@
export type { LogEntry, LogLevel } from "./log-entry.types.js";
export type { PeerInfo } from "./peer-info.types.js";

View File

@@ -0,0 +1,67 @@
# `log-entry.types`
## Purpose
Defines the `LogLevel` discriminated union and the `LogEntry` value object
that the demo's `<LogPanel>` consumes. Both types are shared across the demo
hook (which appends entries) and the panel (which renders them), so the
contract lives in the top-level `types/` folder to avoid a circular import
between sibling component packages.
## Features
- `LogLevel = "info" | "error" | "warning" | "success"` — four severity
classes mapping cleanly to render colours without sub-string parsing.
- `LogEntry` with `{ id, timestamp, level, message }`, all `readonly`.
- Timestamps are unix-ms numbers — no `Date` objects so the value is
trivially JSON-serialisable for tests.
- Each entry carries a string `id`; the hook generates a monotonic key so
React's reconciler never collides on identical timestamps.
## Usage: Basic Example
```ts
import type { LogEntry } from "@suaveplan/react-network-libp2p-demo";
const entry: LogEntry = {
id: "1",
timestamp: Date.now(),
level: "info",
message: "Node starting…",
};
```
## Usage: Advanced Example
```tsx
import type { LogEntry, LogLevel } from "@suaveplan/react-network-libp2p-demo";
function appendLog(prev: LogEntry[], level: LogLevel, message: string) {
return [
...prev.slice(-100),
{
id: `${prev.length + 1}`,
timestamp: Date.now(),
level,
message,
},
] satisfies LogEntry[];
}
```
## API Reference
- `LogLevel` — string-literal union: `"info" | "error" | "warning" | "success"`.
- `LogEntry` — `{ id: string; timestamp: number; level: LogLevel; message: string }`,
all `readonly`.
## Implementation Notes
The `id` field is mandatory specifically because two log entries appended
inside the same microtask can share a millisecond. Without a stable id,
React's list reconciler would emit a duplicate-key warning on every fast
event burst (peer-discovery storms hit this immediately). Generating ids
inside the hook lets `<LogPanel>` stay a pure presentational component.
The level set is deliberately small — four colours is enough to encode
operator intent (success ✓, warning ⚠, error ✗, info i) without overwhelming
the panel layout, and matches the colour palette used by upstream's demo.

View File

@@ -0,0 +1,17 @@
/**
* Severity classes for log lines rendered in the demo's `<LogPanel>`. The
* `success` and `warning` levels are separate from `info`/`error` so the
* panel can colour-code without inspecting message strings.
*/
export type LogLevel = "info" | "error" | "warning" | "success";
/**
* Single line in the demo's debug log. Timestamps are unix-ms produced by
* an injectable clock; the panel formats with the locale's time-only string.
*/
export interface LogEntry {
readonly id: string;
readonly timestamp: number;
readonly level: LogLevel;
readonly message: string;
}

View File

@@ -0,0 +1,65 @@
# `peer-info.types`
## Purpose
Defines the `PeerInfo` snapshot value object that the demo's `<PeerList>`
panel renders. The shape is intentionally structural so the demo never
imports any libp2p type tree directly — the underlying transport
(`@suaveplan/network-transport-libp2p`) translates `peer:discovery`,
`peer:connect`, and `peer:disconnect` events into `PeerInfo` records before
they enter the React tree.
## Features
- `id` — the peer's libp2p PeerID rendered as its base58 string form.
- `protocols` — list of supported multistream protocol ids; empty array when
the peer is only discovered (not yet identified).
- `addresses` — list of dialable multiaddrs.
- `connected` — boolean indicating whether the local node holds an open
connection to the peer right now.
- All fields `readonly`; the array fields are `readonly string[]` so a
consumer accidentally calling `.push()` is a compile-time error.
## Usage: Basic Example
```ts
import type { PeerInfo } from "@suaveplan/react-network-libp2p-demo";
const peer: PeerInfo = {
id: "12D3KooW…",
protocols: ["/ipfs/ping/1.0.0"],
addresses: ["/ip4/192.0.2.1/tcp/4001"],
connected: true,
};
```
## Usage: Advanced Example
```tsx
import type { PeerInfo } from "@suaveplan/react-network-libp2p-demo";
import { PeerList } from "@suaveplan/react-network-libp2p-demo";
function ConnectedOnly({ peers }: { peers: readonly PeerInfo[] }) {
const connected = peers.filter((p) => p.connected);
return <PeerList title="Connected" peers={connected} onSelect={() => undefined} />;
}
```
## API Reference
- `PeerInfo` — `{ id: string; protocols: readonly string[]; addresses:
readonly string[]; connected: boolean }`, all `readonly`.
## Implementation Notes
The structural shape lets the demo render mock data in Storybook without
spinning up a real libp2p node. Two-state design (`connected: boolean`) is
sufficient for the demo UI — production consumers wanting richer state
(idle, dialing, identified-only) should build on top of the transport
package directly rather than extending this DTO. The id is rendered
truncated in the peer-list buttons (`id.substring(0, 20) + "…"`) which is
purely a presentation concern; the full id is preserved on the record so
the dial / ping / echo handlers receive the full string verbatim. Keeping
this type structural also lets test fixtures construct `PeerInfo` records
directly without indirecting through libp2p's `PeerId` constructor, which
keeps unit tests fast and dependency-free.

View File

@@ -0,0 +1,12 @@
/**
* Snapshot of a peer the demo has either discovered or connected to.
* Mirrors the small subset of libp2p's peer-event detail surface that
* the demo's `<PeerList>` panel renders, kept structural so consumers
* are not forced to import the libp2p type tree.
*/
export interface PeerInfo {
readonly id: string;
readonly protocols: readonly string[];
readonly addresses: readonly string[];
readonly connected: boolean;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("react-network-libp2p-demo dep-graph", () => {
it("loads index and exposes every public surface", async () => {
const mod = await import("../src/index.js");
// Errors
expect(mod.REACT_LIBP2P_DEMO_CODES).toBeDefined();
expect(mod.ReactLibp2pDemoError).toBeDefined();
// Hook + schemas + helpers
expect(mod.useLibp2pDemo).toBeDefined();
expect(mod.UseLibp2pDemoOptionsSchema).toBeDefined();
expect(mod.wirePeerEvents).toBeDefined();
// Components
expect(mod.Libp2pDemo).toBeDefined();
expect(mod.LogPanel).toBeDefined();
expect(mod.NodeControls).toBeDefined();
expect(mod.NodeStatusPanel).toBeDefined();
expect(mod.PeerInteractionPanel).toBeDefined();
expect(mod.PeerList).toBeDefined();
});
});

View File

@@ -0,0 +1,126 @@
import type {
DemoPeerEvent,
DemoSubscription,
Libp2pDemoNode,
Libp2pDemoNodeConfig,
Libp2pDemoNodeFactory,
} from "../../src/hooks/use-libp2p-demo/use-libp2p-demo.types.js";
/**
* Deterministic fake `Libp2pDemoNode` used across the unit test suite.
* No mocks, no spies — just a real implementation of the structural
* contract that records calls and exposes typed event emitters.
*/
export interface FakeNodeOptions {
readonly peerId?: string;
readonly listenAddresses?: readonly string[];
readonly dialResult?: string;
readonly pingResult?: number;
readonly echoResult?: string;
readonly throwOnStart?: Error;
readonly throwOnStop?: Error;
readonly throwOnDial?: Error;
readonly throwOnPing?: Error;
readonly throwOnEcho?: Error;
}
export interface FakeNode extends Libp2pDemoNode {
readonly emitDiscovery: (evt: DemoPeerEvent) => void;
readonly emitConnect: (evt: DemoPeerEvent) => void;
readonly emitDisconnect: (evt: DemoPeerEvent) => void;
readonly startedCount: () => number;
readonly stoppedCount: () => number;
readonly lastConfig: () => Libp2pDemoNodeConfig | null;
}
export function createFakeNode(options: FakeNodeOptions = {}): FakeNode {
const discoveryHandlers = new Set<(evt: DemoPeerEvent) => void>();
const connectHandlers = new Set<(evt: DemoPeerEvent) => void>();
const disconnectHandlers = new Set<(evt: DemoPeerEvent) => void>();
let starts = 0;
let stops = 0;
let lastConfig: Libp2pDemoNodeConfig | null = null;
const sub =
(set: Set<(evt: DemoPeerEvent) => void>) =>
(handler: (evt: DemoPeerEvent) => void): DemoSubscription => {
set.add(handler);
return {
unsubscribe: () => {
set.delete(handler);
},
};
};
const node: FakeNode = {
peerId: options.peerId ?? "12D3KooWFakeNode",
listenAddresses: options.listenAddresses ?? ["/ip4/127.0.0.1/tcp/0"],
start: async () => {
starts += 1;
if (options.throwOnStart) throw options.throwOnStart;
},
stop: async () => {
stops += 1;
if (options.throwOnStop) throw options.throwOnStop;
},
dial: async (_addr, opts) => {
if (opts?.signal?.aborted)
throw opts.signal.reason ?? new Error("aborted");
if (options.throwOnDial) throw options.throwOnDial;
return options.dialResult ?? "12D3KooWFakeRemote";
},
ping: async (_target, opts) => {
if (opts?.signal?.aborted)
throw opts.signal.reason ?? new Error("aborted");
if (options.throwOnPing) throw options.throwOnPing;
return options.pingResult ?? 42;
},
echo: async (_target, message, opts) => {
if (opts?.signal?.aborted)
throw opts.signal.reason ?? new Error("aborted");
if (options.throwOnEcho) throw options.throwOnEcho;
return options.echoResult ?? message;
},
onPeerDiscovery: sub(discoveryHandlers),
onPeerConnect: sub(connectHandlers),
onPeerDisconnect: sub(disconnectHandlers),
emitDiscovery: (evt) => {
for (const h of discoveryHandlers) h(evt);
},
emitConnect: (evt) => {
for (const h of connectHandlers) h(evt);
},
emitDisconnect: (evt) => {
for (const h of disconnectHandlers) h(evt);
},
startedCount: () => starts,
stoppedCount: () => stops,
lastConfig: () => lastConfig,
};
// Expose a setter via a separate factory so the holder for lastConfig
// is captured by the factory closure below.
(
node as { _setLastConfig?: (c: Libp2pDemoNodeConfig) => void }
)._setLastConfig = (c: Libp2pDemoNodeConfig) => {
lastConfig = c;
};
return node;
}
export function fakeNodeFactory(
nodeOrOptions: FakeNode | FakeNodeOptions = {}
): Libp2pDemoNodeFactory {
return async (config) => {
const node: FakeNode =
"emitDiscovery" in (nodeOrOptions as object)
? (nodeOrOptions as FakeNode)
: createFakeNode(nodeOrOptions as FakeNodeOptions);
const setter = (
node as { _setLastConfig?: (c: Libp2pDemoNodeConfig) => void }
)._setLastConfig;
setter?.(config);
return node;
};
}

View File

@@ -0,0 +1,11 @@
// React-specific polyfills. The base webcrypto polyfill loads via
// `@suaveplan/testing/polyfills`. This sets the React 18+
// `IS_REACT_ACT_ENVIRONMENT` flag so DOM assertions outside `act(...)`
// don't generate runtime warnings.
interface ReactActEnvWindow {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
const win = globalThis as unknown as ReactActEnvWindow;
if (win.IS_REACT_ACT_ENVIRONMENT === undefined) {
win.IS_REACT_ACT_ENVIRONMENT = true;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"jsx": "react-jsx"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.bench.ts",
"src/**/*.stories.tsx",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,44 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/react-network-libp2p-demo",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/network-transport-libp2p",
"@suaveplan/types",
"tslib",
"zod",
"react",
"react-dom",
"react/jsx-runtime",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,44 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
setupFiles: [
"@suaveplan/testing/polyfills",
"./tests/setup/polyfills.ts",
],
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"tests/**/*.test.ts",
],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/*.stories.tsx",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});