| name | local-dev |
| description | Build, publish, and debug workflows for local development. Use when iterating on UI, debugging message sending, or testing on mobile devices. |
Local Development
Build, publish, and debug workflows. For general Freenet local node management,
contract publishing, and debugging patterns, see the local-dev skill in the
freenet-agent-skills plugin.
Quick Start
Prerequisites
which freenet fdev dx
rustup target add wasm32-unknown-unknown
One-time setup
cargo build --release -p web-container-tool
cargo build --release --target wasm32-unknown-unknown -p web-container-contract
mkdir -p test-contract
target/release/web-container-tool generate --output test-contract/test-keys.toml
LOG_DIR=~/Library/Logs/freenet-test-node
mkdir -p "$LOG_DIR"
freenet network \
--network-port 31338 \
--ws-api-port 7510 \
--ws-api-address 0.0.0.0 \
--is-gateway \
--skip-load-from-network \
--data-dir ~/freenet-test-node/data \
--public-network-address 127.0.0.1 \
--log-dir "$LOG_DIR" \
--log-level debug
Fast iteration script
./scripts/local-republish.sh
./scripts/local-republish.sh --skip-build
./scripts/local-republish.sh --port 7509
The script outputs desktop and phone URLs after publishing. Use Playwright
MCP tools to verify the result without opening a manual browser — see
"Automated verification with Playwright MCP" below.
Build Commands
Individual components
cargo build --release --target wasm32-unknown-unknown -p room-contract
cargo build --release --target wasm32-unknown-unknown -p chat-delegate
cargo build --release --target wasm32-unknown-unknown -p web-container-contract
(cd ui && dx build --release)
Development mode
cargo make dev
cargo make dev-example
Fast Iteration Loop
UI changes only (fastest)
./scripts/local-republish.sh
Contract changes
cargo build --release --target wasm32-unknown-unknown -p room-contract
cp target/wasm32-unknown-unknown/release/room_contract.wasm ui/public/contracts/
./scripts/local-republish.sh
Delegate changes
cargo build --release --target wasm32-unknown-unknown -p chat-delegate
./scripts/local-republish.sh
Manual publish
The cargo make publish tasks cross-compile the web-container-tool for
x86_64-unknown-linux-gnu. On other platforms, use local-republish.sh
or run the steps manually:
(cd ui && dx build --release)
(cd target/dx/river-ui/release/web/public && tar -cJf ../../../../../webapp/webapp.tar.xz *)
target/release/web-container-tool sign \
--input target/webapp/webapp.tar.xz \
--output target/webapp/webapp-test.metadata \
--parameters target/webapp/webapp-test.parameters \
--key-file test-contract/test-keys.toml \
--version $(( $(date +%s) / 60 ))
fdev --port 7510 execute put \
--code target/wasm32-unknown-unknown/release/web_container_contract.wasm \
--parameters target/webapp/webapp-test.parameters \
contract \
--webapp-archive target/webapp/webapp.tar.xz \
--webapp-metadata target/webapp/webapp-test.metadata
Debugging
Automated verification with Playwright MCP
The Playwright MCP plugin is enabled (.claude/settings.local.json). Use it
to verify UI changes without manually opening a browser.
After publishing to a local node:
browser_navigate → URL from local-republish.sh output
browser_snapshot → verify layout and content render correctly
browser_fill_form + browser_click → test message sending
browser_console_messages → check for WASM panics or errors
browser_resize → test mobile breakpoints (767px, 480px)
Against example data (no node needed):
cargo make build-ui-example-no-sync
cd target/dx/river-ui/release/web/public && python3 -m http.server 8082 &
Then browser_navigate → http://127.0.0.1:8082/
Typical iteration loop:
- Edit UI code
./scripts/local-republish.sh
browser_navigate to published URL
browser_snapshot to verify
browser_console_messages to check for errors
Debug overlay
Built-in debug overlay activated via ?debug=1 query parameter. Shows
timestamped log messages on-screen with a minimize/expand toggle — essential
for mobile where console is inaccessible.
http://{IP}:7510/v1/contract/web/{CONTRACT_ID}/?debug=1
Use crate::util::debug_log("msg") to log to the overlay. Does nothing
without ?debug=1.
Panic overlay
A WASM panic hook creates a visible red error overlay showing the panic
message. Appears automatically on any crash, no query param needed.
Delegate signing flow
Message sending uses a delegate-based signing architecture:
- Room creation →
create_room_modal.rs generates SigningKey, stores in ROOMS signal, and calls store_signing_key() to save it in the chat delegate
- Message send → UI calls
sign_message_with_fallback(room_key, msg, fallback_sk)
- Delegate signing →
send_delegate_request(SignMessage{...}) → delegate looks up signing_key:{origin}:{room_key} → returns signature
- Fallback → If delegate fails, signs locally with
fallback_sk.sign()
- Delta applied → Message added to local state →
NEEDS_SYNC set → ProcessRooms → UPDATE sent
Key debugging points:
- Node logs show
"Sign request for room, signature created: true/false" — if false, delegate doesn't have the key
- Browser console shows fallback path:
"Delegate signing failed, using fallback"
- If no UPDATE appears in node logs after signing, check if WebSocket is still connected
Check contract state via riverctl
riverctl --node-url ws://127.0.0.1:7510/v1/contract/command?encodingProtocol=native room list
Timeline analysis for message send
SignMessage received → delegate got the sign request
signature created: true/false → delegate had (or didn't have) the key
Update { key: ... } → UPDATE arrived at node
ResultRouter received result → UPDATE processed, result sent back to client
If step 1 happens but step 3 doesn't, the browser died between signing and sending the UPDATE.
Firefox mobile: Dioxus RefCell re-entrant borrow panics
Firefox mobile runs Dioxus signal subscriber notifications synchronously
during Drop, unlike Chrome/Safari which defer to microtask boundaries. This
causes RefCell already borrowed panics in WASM at three levels:
-
Dioxus signal re-entrancy — ROOMS.with_mut() Drop triggers subscriber
notifications that cascade into ROOMS.read(). Fix: use try_read() for
all reactive signal reads. try_read() still registers Dioxus subscriptions
(confirmed in Dioxus 0.7.x source) but returns Err instead of panicking.
-
wasm-bindgen-futures task re-entrancy — spawn_local inside a polled
future causes re-entrant Task::run() at singlethread.rs:132. Fix: use
safe_spawn_local() helper (in util.rs) that wraps spawn_local in
setTimeout(0) to break out of the WASM call stack.
-
Signal mutation inside spawn_local — ROOMS.with_mut() inside a
spawn_local task triggers notifications that re-queue the same task. Fix:
move signal mutations out of spawn_local via setTimeout(0).
Key pattern for safe signal writes in WASM:
spawn_local(async {
ROOMS.with_mut(|rooms| { });
});
#[cfg(target_arch = "wasm32")]
{
let cb = Closure::once_into_js(move || {
ROOMS.with_mut(|rooms| { });
});
web_sys::window().unwrap()
.set_timeout_with_callback(&cb.into()).ok();
}
Important: Signal clears in use_effect must be synchronous, not deferred.
Deferring a clear that the effect subscribes to causes an infinite loop.
See mark_needs_sync() in app.rs and safe_spawn_local() in util.rs
for canonical examples.
Common issues
| Symptom | Cause | Fix |
|---|
| Messages fail to send (new room) | Signing key not stored in delegate | Fixed: create_room_modal.rs now calls store_signing_key after room creation |
| "signature created: false" in node logs | Delegate can't find signing key for room | Ensure StoreSigningKey is sent after room creation; fallback signs locally |
| Mobile send appears stuck | Browser suspends WASM when screen locks | Keep phone screen active; delegate signing avoids long async chains |
RefCell already borrowed on Firefox mobile | Dioxus signal re-entrant borrow during Drop | Use try_read() instead of read() for reactive signal access |
Crash at singlethread.rs:132 | spawn_local inside polled future on Firefox | Use safe_spawn_local() to defer via setTimeout(0) |
| Blank page after code change (no panic) | Infinite loop from deferred signal clear | Keep signal clears synchronous in use_effect; only defer spawns |