diff --git a/.cspell.json b/.cspell.json index dd57575bc2..e6c6f1fa0a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -55,6 +55,7 @@ "fontsource", "globby", "gossipsub", + "hackathons", "huilong", "iasked", "ihave", @@ -62,6 +63,7 @@ "ineed", "IPAM", "ipfs", + "isready", "iwant", "jdev", "jswaku", @@ -165,6 +167,7 @@ "gen", "proto", "*.spec.ts", + "*.log", "CHANGELOG.md" ], "patterns": [ diff --git a/.gitignore b/.gitignore index 80d8fb41fb..1e6952cf2f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,6 @@ packages/discovery/mock_local_storage .giga .cursor .DS_Store -CLAUDE.md \ No newline at end of file +CLAUDE.md +.env +postgres-data/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 288bdb7fbc..210d52b6a6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -10,5 +10,6 @@ "packages/discovery": "0.0.12", "packages/sds": "0.0.7", "packages/rln": "0.1.9", - "packages/react": "0.0.7" + "packages/react": "0.0.7", + "packages/run": "0.0.1" } diff --git a/package-lock.json b/package-lock.json index 370aa3b75a..aec015a416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "packages/rln", "packages/sdk", "packages/relay", + "packages/run", "packages/tests", "packages/reliability-tests", "packages/browser-tests", @@ -7643,6 +7644,10 @@ "resolved": "packages/rln", "link": true }, + "node_modules/@waku/run": { + "resolved": "packages/run", + "link": true + }, "node_modules/@waku/sdk": { "resolved": "packages/sdk", "link": true @@ -35978,6 +35983,33 @@ "uuid": "dist/esm/bin/uuid" } }, + "packages/run": { + "name": "@waku/run", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@waku/core": "*", + "@waku/interfaces": "*", + "@waku/sdk": "*", + "@waku/utils": "*" + }, + "bin": { + "waku-run": "dist/src/cli.js" + }, + "devDependencies": { + "@types/chai": "^4.3.11", + "@types/mocha": "^10.0.6", + "chai": "^4.3.10", + "cspell": "^8.6.1", + "mocha": "^10.3.0", + "npm-run-all": "^4.1.5", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=22" + } + }, "packages/sdk": { "name": "@waku/sdk", "version": "0.0.35", diff --git a/package.json b/package.json index f406982678..02a8034760 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/rln", "packages/sdk", "packages/relay", + "packages/run", "packages/tests", "packages/reliability-tests", "packages/browser-tests", diff --git a/packages/run/.eslintrc.cjs b/packages/run/.eslintrc.cjs new file mode 100644 index 0000000000..c353a0323b --- /dev/null +++ b/packages/run/.eslintrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + parserOptions: { + tsconfigRootDir: __dirname, + project: "./tsconfig.dev.json" + }, + rules: { + "@typescript-eslint/no-non-null-assertion": "off" + }, + globals: { + process: true + }, + overrides: [ + { + files: ["*.js"], + rules: { + "no-console": "error" + } + } + ] +}; diff --git a/packages/run/.mocharc.cjs b/packages/run/.mocharc.cjs new file mode 100644 index 0000000000..424cc14a01 --- /dev/null +++ b/packages/run/.mocharc.cjs @@ -0,0 +1,11 @@ +module.exports = { + extension: ['ts'], + require: ['ts-node/register'], + loader: 'ts-node/esm', + 'node-option': [ + 'experimental-specifier-resolution=node', + 'loader=ts-node/esm' + ], + timeout: 90000, + exit: true +}; diff --git a/packages/run/README.md b/packages/run/README.md new file mode 100644 index 0000000000..a05a2bb0b5 --- /dev/null +++ b/packages/run/README.md @@ -0,0 +1,148 @@ +# @waku/run + +> **Spin up a local Waku network for development without relying on external infrastructure** + +Perfect for hackathons, offline development, or when you need a controlled testing environment for your js-waku application. + +## What's Included + +- **2 nwaku nodes** connected to each other with all protocols enabled: +- **PostgreSQL database** for message persistence +- **Isolated network** - nodes only connect to each other + +## Requirements + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine with Compose plugin + +## Quick Start + +### 1. Start the Network + +```bash +npx @waku/run start +``` + +This will: +- Start 2 nwaku nodes and a PostgreSQL database +- Run in the background (detached mode) +- Display connection information you need for your app + +**Example output:** +```typescript +import { createLightNode } from "@waku/sdk"; + +const waku = await createLightNode({ + defaultBootstrap: false, + bootstrapPeers: [ + "/ip4/127.0.0.1/tcp/60000/ws/p2p/16Uiu2HAmF6oAsd23RMAnZb3NJgxXrExxBTPMdEoih232iAZkviU2", + "/ip4/127.0.0.1/tcp/60001/ws/p2p/16Uiu2HAm5aZU47YkiUoARqivbCXwuFPzFFXXiURAorySqAQbL6EQ" + ], + numPeersToUse: 2, + libp2p: { + filterMultiaddrs: false + }, + networkConfig: { + clusterId: 0, + numShardsInCluster: 8 + } +}); +``` + +### 2. Connect Your js-waku App + +Copy the configuration from the output above and paste it into your application. Then start your node: + +```typescript +await waku.start(); + +// Your app is now connected to your local Waku network! +``` + +### 3. Stop When Done + +```bash +npx @waku/run stop +``` + +## Available Commands + +### Using npx (published package) + +| Command | Description | +|---------|-------------| +| `npx @waku/run start` | Start the network (detached) and show connection info | +| `npx @waku/run stop` | Stop the network and clean up | +| `npx @waku/run info` | Show connection info for running network | +| `npx @waku/run logs` | View and follow logs from all nodes | +| `npx @waku/run test` | Test the network by sending a message | + +## Configuration + +All configuration is done via environment variables passed to the command. + +### Custom Ports + +If the default ports are in use, specify custom ports: + +```bash +NODE1_WS_PORT=50000 NODE2_WS_PORT=50001 npx @waku/run start +``` + +Available port configuration: +- `NODE1_WS_PORT` (default: 60000) +- `NODE2_WS_PORT` (default: 60001) +- `NODE1_REST_PORT` (default: 8646) +- `NODE2_REST_PORT` (default: 8647) + +### Cluster Configuration + +The default configuration uses: +- Cluster ID: 0 +- Number of shards: 8 + +To test with a different cluster: + +```bash +CLUSTER_ID=16 npx @waku/run start +``` + +### Custom nwaku Version + +To use a different nwaku image version: + +```bash +NWAKU_IMAGE=wakuorg/nwaku:v0.35.0 npx @waku/run start +``` + +## Debugging + +### View Node Logs + +```bash +npx @waku/run logs +``` + +### Check Node Health + +```bash +# Node 1 +curl http://127.0.0.1:8646/health + +# Node 2 +curl http://127.0.0.1:8647/health +``` + +### Check Peer Connections + +```bash +# Node 1 debug info +curl http://127.0.0.1:8646/debug/v1/info + +# Node 2 debug info +curl http://127.0.0.1:8647/debug/v1/info +``` + + +## License + +MIT OR Apache-2.0 diff --git a/packages/run/docker-compose.yml b/packages/run/docker-compose.yml new file mode 100644 index 0000000000..4634db9627 --- /dev/null +++ b/packages/run/docker-compose.yml @@ -0,0 +1,142 @@ +# Environment variable definitions +x-pg-pass: &pg_pass ${POSTGRES_PASSWORD:-test123} +x-pg-user: &pg_user ${POSTGRES_USER:-postgres} + +x-pg-environment: &pg_env + POSTGRES_USER: *pg_user + POSTGRES_PASSWORD: *pg_pass + +# Shared nwaku configuration +x-nwaku-base: &nwaku-base + image: ${NWAKU_IMAGE:-wakuorg/nwaku:v0.36.0} + pull_policy: if_not_present + restart: on-failure + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + postgres: + image: postgres:15.4-alpine3.18 + pull_policy: if_not_present + restart: on-failure + environment: + <<: *pg_env + POSTGRES_DB: postgres + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + nwaku-1: + <<: *nwaku-base + container_name: ${COMPOSE_PROJECT_NAME:-waku-run-0-0-1}-node-1 + networks: + default: + ipv4_address: 172.20.0.10 + ports: + - "${NODE1_TCP_PORT:-30303}:30303/tcp" + - "${NODE1_WS_PORT:-60000}:60000/tcp" + - "${NODE1_REST_PORT:-8646}:8646/tcp" + environment: + <<: *pg_env + depends_on: + postgres: + condition: service_healthy + command: + - --nodekey=e419c3cf4f09ac3babdf61856e6faa0e0c6a7d97674d5401a0114616549c7632 + - --staticnode=/ip4/172.20.0.11/tcp/60001/ws/p2p/16Uiu2HAm5aZU47YkiUoARqivbCXwuFPzFFXXiURAorySqAQbL6EQ + - --relay=true + - --filter=true + - --lightpush=true + - --store=true + - --peer-exchange=true + - --discv5-discovery=true + - --cluster-id=0 + - --shard=0 + - --shard=1 + - --shard=2 + - --shard=3 + - --shard=4 + - --shard=5 + - --shard=6 + - --shard=7 + - --listen-address=0.0.0.0 + - --tcp-port=30303 + - --websocket-support=true + - --websocket-port=60000 + - --ext-multiaddr=/dns4/nwaku-1/tcp/60000/ws + - --ext-multiaddr=/ip4/127.0.0.1/tcp/60000/ws + - --rest=true + - --rest-address=0.0.0.0 + - --rest-port=8646 + - --rest-admin=true + - --store-message-db-url=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-test123}@postgres:5432/nwaku1 + - --log-level=${LOG_LEVEL:-INFO} + - --max-connections=150 + + nwaku-2: + <<: *nwaku-base + container_name: ${COMPOSE_PROJECT_NAME:-waku-run-0-0-1}-node-2 + networks: + default: + ipv4_address: 172.20.0.11 + ports: + - "${NODE2_TCP_PORT:-30304}:30304/tcp" + - "${NODE2_WS_PORT:-60001}:60001/tcp" + - "${NODE2_REST_PORT:-8647}:8647/tcp" + environment: + <<: *pg_env + depends_on: + postgres: + condition: service_healthy + nwaku-1: + condition: service_started + command: + - --nodekey=50632ab0efd313bfb4aa842de716f03dacd181c863770abd145e3409290fdaa7 + - --staticnode=/ip4/172.20.0.10/tcp/60000/ws/p2p/16Uiu2HAmF6oAsd23RMAnZb3NJgxXrExxBTPMdEoih232iAZkviU2 + - --relay=true + - --filter=true + - --lightpush=true + - --store=true + - --peer-exchange=true + - --discv5-discovery=true + - --cluster-id=0 + - --shard=0 + - --shard=1 + - --shard=2 + - --shard=3 + - --shard=4 + - --shard=5 + - --shard=6 + - --shard=7 + - --listen-address=0.0.0.0 + - --tcp-port=30304 + - --websocket-support=true + - --websocket-port=60001 + - --ext-multiaddr=/dns4/nwaku-2/tcp/60001/ws + - --ext-multiaddr=/ip4/127.0.0.1/tcp/60001/ws + - --rest=true + - --rest-address=0.0.0.0 + - --rest-port=8647 + - --rest-admin=true + - --store-message-db-url=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-test123}@postgres:5432/nwaku2 + - --log-level=${LOG_LEVEL:-INFO} + - --max-connections=150 + +volumes: + postgres-data: + +networks: + default: + name: ${COMPOSE_PROJECT_NAME:-waku-run-0-0-1}-network + ipam: + config: + - subnet: 172.20.0.0/16 diff --git a/packages/run/init-db.sh b/packages/run/init-db.sh new file mode 100755 index 0000000000..35a95a7560 --- /dev/null +++ b/packages/run/init-db.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +# Create separate databases for each nwaku node +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE DATABASE nwaku1; + CREATE DATABASE nwaku2; +EOSQL diff --git a/packages/run/package.json b/packages/run/package.json new file mode 100644 index 0000000000..7c72459fa3 --- /dev/null +++ b/packages/run/package.json @@ -0,0 +1,68 @@ +{ + "name": "@waku/run", + "version": "0.0.1", + "description": "Run a local Waku network for development and testing", + "type": "module", + "author": "Waku Team", + "homepage": "https://github.com/waku-org/js-waku/tree/master/packages/run#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/waku-org/js-waku.git" + }, + "bugs": { + "url": "https://github.com/waku-org/js-waku/issues" + }, + "license": "MIT OR Apache-2.0", + "keywords": [ + "waku", + "decentralized", + "communication", + "web3", + "testing", + "development" + ], + "bin": { + "waku-run": "./dist/src/cli.js" + }, + "files": [ + "dist", + "docker-compose.yml", + "init-db.sh", + "README.md" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build", + "start": "node dist/scripts/start.js", + "stop": "node dist/scripts/stop.js", + "restart": "npm run stop && npm run start", + "logs": "node dist/scripts/logs.js", + "info": "node dist/scripts/info.js", + "test": "if [ \"$CI\" = \"true\" ]; then echo 'Skipping tests in CI'; exit 0; fi && NODE_ENV=test node ./src/run-tests.js \"tests/basic.spec.ts\"", + "fix": "run-s fix:*", + "fix:lint": "eslint src scripts tests --fix", + "check": "run-s check:*", + "check:tsc": "tsc -p tsconfig.dev.json", + "check:lint": "eslint src scripts tests", + "check:spelling": "cspell \"{README.md,src/**/*.ts,scripts/**/*.ts,tests/**/*.ts}\"" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@waku/core": "*", + "@waku/interfaces": "*", + "@waku/sdk": "*", + "@waku/utils": "*" + }, + "devDependencies": { + "@types/chai": "^4.3.11", + "@types/mocha": "^10.0.6", + "chai": "^4.3.10", + "cspell": "^8.6.1", + "mocha": "^10.3.0", + "npm-run-all": "^4.1.5", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/packages/run/scripts/info.ts b/packages/run/scripts/info.ts new file mode 100755 index 0000000000..98259f21d3 --- /dev/null +++ b/packages/run/scripts/info.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { + DEFAULT_CLUSTER_ID, + DEFAULT_NODE1_WS_PORT, + DEFAULT_NODE2_WS_PORT, + NODE1_PEER_ID, + NODE2_PEER_ID +} from "../src/constants.js"; +import { getProjectName, printWakuConfig } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = __dirname.includes("dist") + ? join(__dirname, "..", "..") + : join(__dirname, ".."); + +interface Colors { + reset: string; + cyan: string; + blue: string; + gray: string; + yellow: string; +} + +// ANSI color codes +const colors: Colors = { + reset: "\x1b[0m", + cyan: "\x1b[36m", + blue: "\x1b[34m", + gray: "\x1b[90m", + yellow: "\x1b[33m" +}; + +try { + // Check if containers are running + const projectName = getProjectName(packageRoot); + const output: string = execSync( + `docker compose --project-name ${projectName} ps --quiet`, + { + cwd: packageRoot, + encoding: "utf-8", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + } + ).trim(); + + if (!output) { + process.stdout.write( + `${colors.gray}No nodes running. Start with: ${colors.cyan}npm run start${colors.reset}\n` + ); + process.exit(0); + } + + // Get cluster config from env or defaults + const clusterId: string = process.env.CLUSTER_ID || DEFAULT_CLUSTER_ID; + const node1Port: string = process.env.NODE1_WS_PORT || DEFAULT_NODE1_WS_PORT; + const node2Port: string = process.env.NODE2_WS_PORT || DEFAULT_NODE2_WS_PORT; + + // Static peer IDs from --nodekey configuration + // cspell:ignore nodekey + const peer1: string = NODE1_PEER_ID; + const peer2: string = NODE2_PEER_ID; + + // Print TypeScript-style config + printWakuConfig(colors, node1Port, node2Port, peer1, peer2, clusterId); +} catch (error: unknown) { + const err = error as { cause?: { code?: string }; message?: string }; + if (err.cause?.code === "ECONNREFUSED") { + process.stderr.write( + `${colors.yellow}⚠${colors.reset} Nodes are still starting. Try again in a few seconds.\n` + ); + process.exit(1); + } else { + process.stderr.write( + `${colors.yellow}✗${colors.reset} Error: ${err.message || String(error)}\n` + ); + process.exit(1); + } +} diff --git a/packages/run/scripts/logs.ts b/packages/run/scripts/logs.ts new file mode 100644 index 0000000000..864a8d6422 --- /dev/null +++ b/packages/run/scripts/logs.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { getProjectName } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = __dirname.includes("dist") + ? join(__dirname, "..", "..") + : join(__dirname, ".."); + +try { + const projectName = getProjectName(packageRoot); + execSync(`docker compose --project-name ${projectName} logs -f`, { + cwd: packageRoot, + stdio: "inherit", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + }); +} catch (error: unknown) { + const err = error as { message?: string }; + process.stderr.write(`Error viewing logs: ${err.message || String(error)}\n`); + process.exit(1); +} diff --git a/packages/run/scripts/start.ts b/packages/run/scripts/start.ts new file mode 100755 index 0000000000..38a6945967 --- /dev/null +++ b/packages/run/scripts/start.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { + DEFAULT_CLUSTER_ID, + DEFAULT_NODE1_WS_PORT, + DEFAULT_NODE2_WS_PORT, + DEFAULT_NWAKU_IMAGE, + NODE1_PEER_ID, + NODE2_PEER_ID, + POSTGRES_IMAGE, + STARTUP_WAIT_MS +} from "../src/constants.js"; +import { getProjectName, printWakuConfig } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = __dirname.includes("dist") + ? join(__dirname, "..", "..") + : join(__dirname, ".."); + +interface Colors { + reset: string; + cyan: string; + green: string; + blue: string; + gray: string; + yellow: string; +} + +// ANSI color codes +const colors: Colors = { + reset: "\x1b[0m", + cyan: "\x1b[36m", + green: "\x1b[32m", + blue: "\x1b[34m", + gray: "\x1b[90m", + yellow: "\x1b[33m" +}; + +function checkAndPullImages(): void { + const nwakuImage = process.env.NWAKU_IMAGE || DEFAULT_NWAKU_IMAGE; + const postgresImage = POSTGRES_IMAGE; + const images = [ + { name: nwakuImage, label: "nwaku" }, + { name: postgresImage, label: "postgres" } + ]; + + for (const { name, label } of images) { + try { + // Check if image exists locally + const imageId = execSync(`docker images -q ${name}`, { + encoding: "utf-8" + }).trim(); + + if (!imageId) { + // Image doesn't exist, pull it + process.stdout.write( + `${colors.cyan}Pulling ${label} image (${name})...${colors.reset}\n` + ); + execSync(`docker pull ${name}`, { stdio: "inherit" }); + process.stdout.write( + `${colors.green}✓${colors.reset} ${label} image ready\n` + ); + } + } catch (error) { + process.stderr.write( + `${colors.yellow}⚠${colors.reset} Failed to check/pull ${label} image. Continuing anyway...\n` + ); + } + } +} + +async function waitWithProgress(ms: number): Promise { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const startTime = Date.now(); + let frameIndex = 0; + + return new Promise((resolve) => { + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + + if (elapsed >= ms) { + clearInterval(interval); + process.stdout.write("\r" + " ".repeat(50) + "\r"); + resolve(); + return; + } + + const frame = frames[frameIndex % frames.length]; + process.stdout.write( + `\r${colors.cyan}${frame}${colors.reset} Waiting for nodes to start...` + ); + frameIndex++; + }, 100); + }); +} + +process.stdout.write( + `${colors.cyan}Starting local Waku development environment...${colors.reset}\n` +); + +try { + // Check and pull images if needed + checkAndPullImages(); + + // Start docker compose from package root + const projectName = getProjectName(packageRoot); + execSync(`docker compose --project-name ${projectName} up -d`, { + cwd: packageRoot, + stdio: ["ignore", "ignore", "pipe"], + encoding: "utf-8", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + }); + + // Wait for nodes to be ready + await waitWithProgress(STARTUP_WAIT_MS); + + // Get cluster config from env or defaults + const clusterId: string = process.env.CLUSTER_ID || DEFAULT_CLUSTER_ID; + const node1Port: string = process.env.NODE1_WS_PORT || DEFAULT_NODE1_WS_PORT; + const node2Port: string = process.env.NODE2_WS_PORT || DEFAULT_NODE2_WS_PORT; + + // Static peer IDs from --nodekey configuration + // cspell:ignore nodekey + const peer1: string = NODE1_PEER_ID; + const peer2: string = NODE2_PEER_ID; + + // Print TypeScript-style config + process.stdout.write( + `${colors.green}✓${colors.reset} Network started successfully!\n\n` + ); + process.stdout.write( + `${colors.gray}Copy this into your application:${colors.reset}\n\n` + ); + + printWakuConfig(colors, node1Port, node2Port, peer1, peer2, clusterId); + process.stdout.write(`\n`); + process.stdout.write(`${colors.gray}Management:${colors.reset}\n`); + + // Detect if running via npx (published package) or npm run (development) + const isPublished = __dirname.includes("dist"); + const cmdPrefix = isPublished ? "npx @waku/run" : "npm run"; + + process.stdout.write( + ` ${colors.cyan}${cmdPrefix} test${colors.reset} - Test network with a message\n` + ); + process.stdout.write( + ` ${colors.cyan}${cmdPrefix} logs${colors.reset} - View logs\n` + ); + process.stdout.write( + ` ${colors.cyan}${cmdPrefix} info${colors.reset} - Show config again\n` + ); + process.stdout.write( + ` ${colors.cyan}${cmdPrefix} stop${colors.reset} - Stop network\n` + ); +} catch (error: unknown) { + const err = error as { cause?: { code?: string }; message?: string }; + if (err.cause?.code === "ECONNREFUSED") { + process.stderr.write( + `${colors.yellow}⚠${colors.reset} Nodes are still starting up. Run ${colors.cyan}npm run info${colors.reset} in a few seconds.\n` + ); + } else { + process.stderr.write( + `${colors.yellow}✗${colors.reset} Error: ${err.message || String(error)}\n` + ); + } + process.exit(1); +} diff --git a/packages/run/scripts/stop.ts b/packages/run/scripts/stop.ts new file mode 100644 index 0000000000..dee6528033 --- /dev/null +++ b/packages/run/scripts/stop.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { getProjectName } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = __dirname.includes("dist") + ? join(__dirname, "..", "..") + : join(__dirname, ".."); + +try { + const projectName = getProjectName(packageRoot); + execSync(`docker compose --project-name ${projectName} down`, { + cwd: packageRoot, + stdio: "inherit", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + }); +} catch (error: unknown) { + const err = error as { message?: string }; + process.stderr.write( + `Error stopping network: ${err.message || String(error)}\n` + ); + process.exit(1); +} diff --git a/packages/run/scripts/test.ts b/packages/run/scripts/test.ts new file mode 100644 index 0000000000..15809f8928 --- /dev/null +++ b/packages/run/scripts/test.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { Protocols } from "@waku/sdk"; + +import { WakuTestClient } from "../src/test-client.js"; +import { getProjectName } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = __dirname.includes("dist") + ? join(__dirname, "..", "..") + : join(__dirname, ".."); + +interface Colors { + reset: string; + cyan: string; + green: string; + red: string; + yellow: string; +} + +// ANSI color codes +const colors: Colors = { + reset: "\x1b[0m", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m" +}; + +async function main(): Promise { + let client: WakuTestClient | null = null; + + try { + // Check if containers are running + const projectName = getProjectName(packageRoot); + const output: string = execSync( + `docker compose --project-name ${projectName} ps --quiet`, + { + cwd: packageRoot, + encoding: "utf-8", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + } + ).trim(); + + if (!output) { + process.stderr.write( + `${colors.red}✗${colors.reset} No nodes running. Start with: ${colors.cyan}npx @waku/run start${colors.reset}\n` + ); + process.exit(1); + } + + process.stdout.write( + `${colors.cyan}Testing local Waku network...${colors.reset}\n\n` + ); + + // Step 1: Create client + process.stdout.write( + `${colors.cyan}→${colors.reset} Creating Waku light node...\n` + ); + client = new WakuTestClient(); + + // Step 2: Start and connect + process.stdout.write(`${colors.cyan}→${colors.reset} Starting node...\n`); + await client.start(); + + // Step 3: Wait for peers + process.stdout.write( + `${colors.cyan}→${colors.reset} Waiting for peers...\n` + ); + await client.waku!.waitForPeers([Protocols.LightPush]); + const connectedPeers = client.waku!.libp2p.getPeers().length; + process.stdout.write( + `${colors.green}✓${colors.reset} Connected to ${connectedPeers} peer(s)\n` + ); + + // Step 4: Send test message + process.stdout.write( + `${colors.cyan}→${colors.reset} Sending lightpush message...\n` + ); + const result = await client.sendTestMessage("Test from @waku/run"); + + if (result.success) { + process.stdout.write( + `${colors.green}✓${colors.reset} Message sent successfully to ${result.messagesSent} peer(s)\n` + ); + process.stdout.write( + `\n${colors.green}✓ All tests passed!${colors.reset}\n` + ); + process.stdout.write( + `${colors.cyan}The local Waku network is working correctly.${colors.reset}\n` + ); + } else { + process.stderr.write( + `${colors.red}✗${colors.reset} Failed to send message: ${result.error || "Unknown error"}\n` + ); + process.stderr.write( + ` Sent: ${result.messagesSent}, Failed: ${result.failures}\n` + ); + process.exit(1); + } + } catch (error: unknown) { + const err = error as { message?: string }; + process.stderr.write( + `${colors.red}✗${colors.reset} Test failed: ${err.message || String(error)}\n` + ); + process.exit(1); + } finally { + if (client) { + await client.stop(); + } + } +} + +main().catch((error) => { + process.stderr.write(`Unexpected error: ${String(error)}\n`); + process.exit(1); +}); diff --git a/packages/run/src/cli.ts b/packages/run/src/cli.ts new file mode 100644 index 0000000000..55fe2161c1 --- /dev/null +++ b/packages/run/src/cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import { spawn } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const command = process.argv[2]; + +const scriptMap: Record = { + start: join(__dirname, "..", "scripts", "start.js"), + stop: join(__dirname, "..", "scripts", "stop.js"), + info: join(__dirname, "..", "scripts", "info.js"), + logs: join(__dirname, "..", "scripts", "logs.js"), + test: join(__dirname, "..", "scripts", "test.js") +}; + +if (!command || !scriptMap[command]) { + process.stderr.write("Usage: @waku/run \n"); + process.stderr.write("\n"); + process.stderr.write("Commands:\n"); + process.stderr.write(" start Start the local Waku network\n"); + process.stderr.write(" stop Stop the local Waku network\n"); + process.stderr.write(" info Show connection info for running network\n"); + process.stderr.write(" logs View logs from running network\n"); + process.stderr.write(" test Test the network by sending a message\n"); + process.exit(1); +} + +const scriptPath = scriptMap[command]; +const child = spawn("node", [scriptPath], { + stdio: "inherit", + env: process.env +}); + +child.on("exit", (code) => { + process.exit(code || 0); +}); diff --git a/packages/run/src/constants.ts b/packages/run/src/constants.ts new file mode 100644 index 0000000000..9b9721d959 --- /dev/null +++ b/packages/run/src/constants.ts @@ -0,0 +1,40 @@ +/** + * Static configuration constants for the local Waku development environment. + * These values are derived from the --nodekey configuration in docker-compose.yml + * cspell:ignore nodekey + */ + +// Node private keys (from docker-compose.yml --nodekey) +export const NODE1_PRIVATE_KEY = + "e419c3cf4f09ac3babdf61856e6faa0e0c6a7d97674d5401a0114616549c7632"; +export const NODE2_PRIVATE_KEY = + "50632ab0efd313bfb4aa842de716f03dacd181c863770abd145e3409290fdaa7"; + +// Derived peer IDs (libp2p identities from the private keys) +export const NODE1_PEER_ID = + "16Uiu2HAmF6oAsd23RMAnZb3NJgxXrExxBTPMdEoih232iAZkviU2"; +export const NODE2_PEER_ID = + "16Uiu2HAm5aZU47YkiUoARqivbCXwuFPzFFXXiURAorySqAQbL6EQ"; + +// Static IP addresses (from docker-compose.yml network configuration) +export const NODE1_IP = "172.20.0.10"; +export const NODE2_IP = "172.20.0.11"; + +// Default WebSocket ports for local nodes +export const DEFAULT_NODE1_WS_PORT = "60000"; +export const DEFAULT_NODE2_WS_PORT = "60001"; + +// Default REST API ports for local nodes +export const DEFAULT_NODE1_REST_PORT = "8646"; +export const DEFAULT_NODE2_REST_PORT = "8647"; + +// Docker images +export const DEFAULT_NWAKU_IMAGE = "wakuorg/nwaku:v0.36.0"; +export const POSTGRES_IMAGE = "postgres:15.4-alpine3.18"; + +// Timing configuration +export const STARTUP_WAIT_MS = 20000; // Time to wait for nodes to start + +// Network configuration +export const DEFAULT_CLUSTER_ID = "0"; +export const DEFAULT_NUM_SHARDS_IN_CLUSTER = 8; diff --git a/packages/run/src/run-tests.js b/packages/run/src/run-tests.js new file mode 100644 index 0000000000..0c4ff82c6e --- /dev/null +++ b/packages/run/src/run-tests.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +import { spawn } from "child_process"; + +const mochaArgs = [ + "mocha", + "--require", + "ts-node/register", + "--project", + "./tsconfig.json", + ...process.argv.slice(2) +]; + +// Run mocha tests +const mocha = spawn("npx", mochaArgs, { + stdio: "inherit", + env: { + ...process.env, + NODE_ENV: "test" + } +}); + +mocha.on("error", (error) => { + console.log(`Error running mocha tests: ${error.message}`); // eslint-disable-line no-console + process.exit(1); +}); + +mocha.on("exit", (code) => { + process.exit(code || 0); +}); diff --git a/packages/run/src/test-client.ts b/packages/run/src/test-client.ts new file mode 100644 index 0000000000..47880fcf93 --- /dev/null +++ b/packages/run/src/test-client.ts @@ -0,0 +1,126 @@ +import { createEncoder } from "@waku/core"; +import type { LightNode } from "@waku/interfaces"; +import { createLightNode } from "@waku/sdk"; +import { createRoutingInfo } from "@waku/utils"; + +import { + DEFAULT_CLUSTER_ID, + DEFAULT_NODE1_WS_PORT, + DEFAULT_NODE2_WS_PORT, + DEFAULT_NUM_SHARDS_IN_CLUSTER, + NODE1_PEER_ID, + NODE2_PEER_ID +} from "./constants.js"; + +export interface WakuTestClientOptions { + node1Port?: string; + node2Port?: string; + clusterId?: number; + numShardsInCluster?: number; + contentTopic?: string; +} + +export interface TestResult { + success: boolean; + connectedPeers: number; + messagesSent: number; + failures: number; + error?: string; +} + +export class WakuTestClient { + public waku: LightNode | null = null; + private options: Required; + + public constructor(options: WakuTestClientOptions = {}) { + this.options = { + node1Port: + options.node1Port || process.env.NODE1_WS_PORT || DEFAULT_NODE1_WS_PORT, + node2Port: + options.node2Port || process.env.NODE2_WS_PORT || DEFAULT_NODE2_WS_PORT, + clusterId: options.clusterId ?? parseInt(DEFAULT_CLUSTER_ID), + numShardsInCluster: + options.numShardsInCluster ?? DEFAULT_NUM_SHARDS_IN_CLUSTER, + contentTopic: options.contentTopic || "/waku-run/1/test/proto" + }; + } + + /** + * Create and start the Waku light node + */ + public async start(): Promise { + const { node1Port, node2Port, clusterId, numShardsInCluster } = + this.options; + + const networkConfig = { + clusterId, + numShardsInCluster + }; + + this.waku = await createLightNode({ + defaultBootstrap: false, + bootstrapPeers: [ + `/ip4/127.0.0.1/tcp/${node1Port}/ws/p2p/${NODE1_PEER_ID}`, + `/ip4/127.0.0.1/tcp/${node2Port}/ws/p2p/${NODE2_PEER_ID}` + ], + networkConfig, + numPeersToUse: 2, + libp2p: { + filterMultiaddrs: false + } + }); + + await this.waku.start(); + } + + /** + * Send a test message via lightpush + */ + public async sendTestMessage( + payload: string = "Hello Waku!" + ): Promise { + if (!this.waku) { + throw new Error("Waku node not started. Call start() first."); + } + + try { + const { contentTopic, clusterId, numShardsInCluster } = this.options; + const networkConfig = { clusterId, numShardsInCluster }; + + const routingInfo = createRoutingInfo(networkConfig, { contentTopic }); + const encoder = createEncoder({ contentTopic, routingInfo }); + + const result = await this.waku.lightPush.send(encoder, { + payload: new TextEncoder().encode(payload) + }); + + const connectedPeers = this.waku.libp2p.getPeers().length; + + return { + success: + result.successes.length > 0 && (result.failures?.length || 0) === 0, + connectedPeers, + messagesSent: result.successes.length, + failures: result.failures?.length || 0 + }; + } catch (error) { + return { + success: false, + connectedPeers: this.waku.libp2p.getPeers().length, + messagesSent: 0, + failures: 0, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + /** + * Stop the Waku node + */ + public async stop(): Promise { + if (this.waku) { + await this.waku.stop(); + this.waku = null; + } + } +} diff --git a/packages/run/src/utils.ts b/packages/run/src/utils.ts new file mode 100644 index 0000000000..a5da9e052a --- /dev/null +++ b/packages/run/src/utils.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { DEFAULT_NUM_SHARDS_IN_CLUSTER } from "./constants.js"; + +export function getProjectName(packageRoot: string): string { + const packageJsonPath = join(packageRoot, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + // Docker Compose project names must consist only of lowercase alphanumeric characters, hyphens, and underscores + const name = packageJson.name.replace("@", "").replace("/", "-"); + const version = packageJson.version.replace(/\./g, "-"); + return `${name}-${version}`; +} + +interface Colors { + reset: string; + cyan: string; + blue: string; + yellow: string; +} + +export function printWakuConfig( + colors: Colors, + node1Port: string, + node2Port: string, + peer1: string, + peer2: string, + clusterId: string +): void { + process.stdout.write( + `${colors.blue}import${colors.reset} { createLightNode } ${colors.blue}from${colors.reset} ${colors.yellow}"@waku/sdk"${colors.reset};\n` + ); + process.stdout.write(`\n`); + process.stdout.write( + `${colors.blue}const${colors.reset} waku = ${colors.blue}await${colors.reset} createLightNode({\n` + ); + process.stdout.write( + ` defaultBootstrap: ${colors.cyan}false${colors.reset},\n` + ); + process.stdout.write(` bootstrapPeers: [\n`); + process.stdout.write( + ` ${colors.yellow}"/ip4/127.0.0.1/tcp/${node1Port}/ws/p2p/${peer1}"${colors.reset},\n` + ); + process.stdout.write( + ` ${colors.yellow}"/ip4/127.0.0.1/tcp/${node2Port}/ws/p2p/${peer2}"${colors.reset}\n` + ); + process.stdout.write(` ],\n`); + process.stdout.write(` numPeersToUse: ${colors.cyan}2${colors.reset},\n`); + process.stdout.write(` libp2p: {\n`); + process.stdout.write( + ` filterMultiaddrs: ${colors.cyan}false${colors.reset}\n` + ); + process.stdout.write(` },\n`); + process.stdout.write(` networkConfig: {\n`); + process.stdout.write( + ` clusterId: ${colors.cyan}${clusterId}${colors.reset},\n` + ); + process.stdout.write( + ` numShardsInCluster: ${colors.cyan}${DEFAULT_NUM_SHARDS_IN_CLUSTER}${colors.reset}\n` + ); + process.stdout.write(` }\n`); + process.stdout.write(`});\n`); +} diff --git a/packages/run/tests/basic.spec.ts b/packages/run/tests/basic.spec.ts new file mode 100644 index 0000000000..0ec9fc0441 --- /dev/null +++ b/packages/run/tests/basic.spec.ts @@ -0,0 +1,120 @@ +import { execSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +import { Protocols } from "@waku/sdk"; +import { expect } from "chai"; + +import { + DEFAULT_NODE1_REST_PORT, + DEFAULT_NODE2_REST_PORT +} from "../src/constants.js"; +import { WakuTestClient } from "../src/test-client.js"; +import { getProjectName } from "../src/utils.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageRoot = join(__dirname, ".."); + +describe("Waku Run - Basic Test", function () { + this.timeout(90000); + + let client: WakuTestClient; + + before(async function () { + // Step 1: Start the nodes + const projectName = getProjectName(packageRoot); + execSync(`docker compose --project-name ${projectName} up -d`, { + cwd: packageRoot, + stdio: "inherit", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + }); + + // Wait for nodes to be ready + const maxRetries = 30; + const retryDelay = 2000; + let ready = false; + + for (let i = 0; i < maxRetries; i++) { + try { + await fetch( + `http://127.0.0.1:${DEFAULT_NODE1_REST_PORT}/debug/v1/info` + ); + await fetch( + `http://127.0.0.1:${DEFAULT_NODE2_REST_PORT}/debug/v1/info` + ); + ready = true; + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + if (!ready) { + throw new Error("Nodes failed to start within expected time"); + } + + // Nodes automatically connect via --staticnode configuration + // cspell:ignore staticnode + // Wait for nwaku nodes to connect to each other + let connected = false; + for (let i = 0; i < 15; i++) { + try { + const peers = await fetch( + `http://127.0.0.1:${DEFAULT_NODE1_REST_PORT}/admin/v1/peers` + ).then((r) => r.json()); + if (peers.length > 0 && peers[0].connected === "Connected") { + connected = true; + break; + } + } catch { + // Ignore errors + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (!connected) { + throw new Error("Nwaku nodes failed to connect to each other"); + } + }); + + after(async function () { + // Step 4: Stop the nodes + if (client) { + await client.stop(); + } + const projectName = getProjectName(packageRoot); + execSync(`docker compose --project-name ${projectName} down`, { + cwd: packageRoot, + stdio: "inherit", + env: { ...process.env, COMPOSE_PROJECT_NAME: projectName } + }); + }); + + it("should connect to both nodes and send lightpush message to both peers", async function () { + // Step 2: Connect to nodes via js-waku using WakuTestClient + client = new WakuTestClient({ + contentTopic: "/test/1/basic/proto" + }); + + await client.start(); + + // Wait for both peers to be connected + await client.waku!.waitForPeers([Protocols.LightPush]); + const connectedPeers = client.waku!.libp2p.getPeers().length; + expect(connectedPeers).to.equal( + 2, + "Should be connected to both nwaku nodes" + ); + + // Step 3: Send lightpush message - it should be sent to both peers + const result = await client.sendTestMessage("Hello Waku!"); + + expect(result.success).to.be.true; + expect(result.messagesSent).to.equal( + 2, + "Message should be sent to both peers" + ); + expect(result.failures).to.equal(0, "Should have no failures"); + }); +}); diff --git a/packages/run/tsconfig.dev.json b/packages/run/tsconfig.dev.json new file mode 100644 index 0000000000..2e8879a53e --- /dev/null +++ b/packages/run/tsconfig.dev.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.dev", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src", "scripts", "tests"] +} diff --git a/packages/run/tsconfig.json b/packages/run/tsconfig.json new file mode 100644 index 0000000000..84417ad4b5 --- /dev/null +++ b/packages/run/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "outDir": "dist/", + "rootDir": ".", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src", "scripts"], + "exclude": ["tests", "dist", "node_modules"] +} diff --git a/release-please-config.json b/release-please-config.json index 622f9b6933..b07c32ed39 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -24,6 +24,7 @@ "packages/discovery": {}, "packages/sds": {}, "packages/rln": {}, - "packages/react": {} + "packages/react": {}, + "packages/run": {} } } \ No newline at end of file