Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Libp2pDemo } from "./libp2p-demo.js";
|
||||
export type { Libp2pDemoProps } from "./libp2p-demo.types.js";
|
||||
@@ -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`.
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LogPanel } from "./log-panel.js";
|
||||
export type { LogPanelProps } from "./log-panel.types.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NodeControls } from "./node-controls.js";
|
||||
export type { NodeControlsProps } from "./node-controls.types.js";
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NodeStatusPanel } from "./node-status-panel.js";
|
||||
export type { NodeStatusPanelProps } from "./node-status-panel.types.js";
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PeerInteractionPanel } from "./peer-interaction-panel.js";
|
||||
export type { PeerInteractionPanelProps } from "./peer-interaction-panel.types.js";
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PeerList } from "./peer-list.js";
|
||||
export type { PeerListProps } from "./peer-list.types.js";
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ReactLibp2pDemoErrorCode } from "./errors.js";
|
||||
export {
|
||||
REACT_LIBP2P_DEMO_CODES,
|
||||
ReactLibp2pDemoError,
|
||||
} from "./errors.js";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { LogEntry, LogLevel } from "./log-entry.types.js";
|
||||
export type { PeerInfo } from "./peer-info.types.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user