feat: skeleton for in browser swactor engine (#35)

Skeleton up for a web browser swactor engine that is capable of connecting with a cluster.
This commit is contained in:
zacheryasc 2026-02-13 13:27:34 +00:00
parent cd1816e398
commit b4312676a6
16 changed files with 1515 additions and 117 deletions

12
Cargo.lock generated
View file

@ -2723,6 +2723,7 @@ dependencies = [
"serde",
"swactor-std",
"tracing",
"web-time",
]
[[package]]
@ -3299,6 +3300,7 @@ name = "wasm"
version = "0.1.0"
dependencies = [
"swactor",
"swactor-std",
"wasm-bindgen",
]
@ -3735,6 +3737,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"

View file

@ -22,11 +22,13 @@ serde = ["dep:serde"]
tracing = ["dep:tracing"]
no_random = [] # compile without access to a source of randomness
transport = [] # transport-agnostic messaging (no mandatory deps; codec is user-provided)
wasm = ["no_random", "dep:web-time"] # browser/wasm32 target support
[dependencies]
getrandom = { version = "0.2", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
tracing = { version = "0.1", optional = true }
web-time = { version = "0.2", optional = true }
crossbeam-queue = "0.3.12"
crossbeam-utils = "0.8.21"

View file

@ -5,8 +5,9 @@ edition = "2024"
[features]
default = ["getrandom"]
getrandom = ["dep:getrandom"]
getrandom = ["dep:getrandom", "swactor/getrandom"]
wasm = ["swactor/wasm"]
[dependencies]
swactor = { path = "../.." }
swactor = { path = "../..", default-features = false }
getrandom = { version = "0.2", optional = true }

View file

@ -102,11 +102,21 @@ impl<M: Message> Router<M> {
Some(live[idx])
}
RoutingStrategy::Random => {
#[cfg(feature = "getrandom")]
{
let mut buf = [0u8; 8];
getrandom::getrandom(&mut buf).expect("getrandom failed");
let r = u64::from_ne_bytes(buf) as usize;
Some(live[r % live.len()])
}
#[cfg(not(feature = "getrandom"))]
{
// Fallback to round-robin when getrandom is unavailable (wasm)
let idx = self.rr_index % live.len();
self.rr_index = self.rr_index.wrapping_add(1);
Some(live[idx])
}
}
RoutingStrategy::Broadcast => None, // handled separately
}
}

View file

@ -17,6 +17,9 @@ fn get_ext(rt: &Runtime) -> &StdExtension {
/// Provides `spawn_named`, `where_is`, `unregister`, and `registered_names`
/// via the [`StdExtension`] name registry.
pub trait RuntimeNaming {
/// Register a name for an already-spawned actor. Returns `Err` if name is taken.
fn register_name(&self, name: impl Into<String>, addr: ActorAddress) -> Result<(), Error>;
/// Spawn an actor with a registered name, returning its address.
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error>;
@ -31,6 +34,10 @@ pub trait RuntimeNaming {
}
impl RuntimeNaming for Runtime {
fn register_name(&self, name: impl Into<String>, addr: ActorAddress) -> Result<(), Error> {
get_ext(self).name_registry.register(name.into(), addr)
}
fn spawn_named<A: ActorInterface>(&self, name: impl Into<String>, actor: A) -> Result<ActorAddress, Error> {
let name = name.into();
let addr = self.spawn(actor)?;

View file

@ -7,5 +7,6 @@ edition = "2024"
crate-type = ["cdylib"]
[dependencies]
swactor = { path = "../..", default-features = false, features = ["no_random"] }
swactor = { path = "../..", default-features = false, features = ["wasm"] }
swactor-std = { path = "../std", default-features = false, features = ["wasm"] }
wasm-bindgen = "0.2"

570
crates/wasm/demo.html Normal file
View file

@ -0,0 +1,570 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>swactor — In-Browser Runtime Demo</title>
<style>
:root {
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #c9d1d9; --dim: #8b949e; --accent: #58a6ff;
--green: #3fb950; --red: #f85149; --yellow: #d29922; --purple: #bc8cff;
--font: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 20px; }
h1 { font-size: 1.4em; margin-bottom: 4px; }
h1 span { color: var(--accent); }
.subtitle { color: var(--dim); font-size: 0.8em; margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: 300px 1fr 280px; gap: 16px; height: calc(100vh - 100px); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-y: auto; }
.panel h2 { font-size: 0.9em; color: var(--accent); margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.stat-row { display: flex; justify-content: space-between; margin-bottom: 6px; font-size: 0.85em; }
.stat-label { color: var(--dim); }
.stat-value { color: var(--green); font-weight: bold; }
.btn {
display: inline-block; padding: 6px 12px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); border-radius: 4px;
cursor: pointer; font-family: var(--font); font-size: 0.8em; transition: 0.15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:active { transform: scale(0.97); }
.btn.danger:hover { border-color: var(--red); color: var(--red); }
.btn.small { padding: 3px 8px; font-size: 0.75em; }
.section { margin-bottom: 16px; }
.section h3 { font-size: 0.8em; color: var(--dim); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
input, select {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 5px 8px; border-radius: 4px; font-family: var(--font); font-size: 0.8em; width: 100%;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.actor-card {
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px; margin-bottom: 8px; font-size: 0.8em; position: relative;
}
.actor-card .type { color: var(--purple); font-weight: bold; }
.actor-card .addr { color: var(--dim); font-size: 0.9em; }
.actor-card .name-tag { color: var(--yellow); font-size: 0.85em; }
.actor-card .group-tag { color: var(--green); font-size: 0.85em; margin-left: 4px; }
.actor-card .actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
#log {
font-size: 0.75em; line-height: 1.6; padding: 8px;
background: var(--bg); border-radius: 4px; height: calc(100% - 40px); overflow-y: auto;
}
.log-entry { border-bottom: 1px solid var(--border); padding: 3px 0; }
.log-time { color: var(--dim); }
.log-spawn { color: var(--green); }
.log-msg { color: var(--accent); }
.log-recv { color: var(--yellow); }
.log-death { color: var(--red); }
.log-name { color: var(--purple); }
.log-group { color: var(--green); }
.tick-indicator {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); margin-right: 6px; transition: 0.1s;
}
.tick-indicator.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
.controls { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.speed-label { font-size: 0.75em; color: var(--dim); }
#loading { text-align: center; padding: 40px; color: var(--dim); font-size: 1.2em; }
#app { display: none; }
.form-row { display: flex; gap: 6px; margin-bottom: 6px; }
.form-row input { flex: 1; }
.viz-canvas { width: 100%; height: 200px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); }
</style>
</head>
<body>
<h1><span>swactor</span> in-browser runtime</h1>
<p class="subtitle">actor runtime compiled to WebAssembly, running right here</p>
<div id="loading">Loading wasm module...</div>
<div id="app">
<div class="grid">
<!-- LEFT: Controls -->
<div class="panel">
<h2>Controls</h2>
<div class="section">
<h3>Runtime</h3>
<div class="controls">
<span class="tick-indicator" id="tick-led"></span>
<button class="btn" id="btn-toggle">Start</button>
<button class="btn" id="btn-step">Step</button>
<div>
<input type="range" id="speed" min="1" max="60" value="20" style="width:80px">
<span class="speed-label" id="speed-label">20 tps</span>
</div>
</div>
<div class="stat-row"><span class="stat-label">Actors</span><span class="stat-value" id="s-actors">0</span></div>
<div class="stat-row"><span class="stat-label">Messages</span><span class="stat-value" id="s-msgs">0</span></div>
<div class="stat-row"><span class="stat-label">Panics</span><span class="stat-value" id="s-panics">0</span></div>
<div class="stat-row"><span class="stat-label">Uptime</span><span class="stat-value" id="s-uptime">0ms</span></div>
<div class="stat-row"><span class="stat-label">Ticks</span><span class="stat-value" id="s-ticks">0</span></div>
</div>
<div class="section">
<h3>Spawn Actor</h3>
<div class="form-row">
<select id="spawn-type">
<option value="counter">Counter</option>
<option value="relay">Relay</option>
<option value="group_member">Group Member</option>
<option value="sentinel">Sentinel</option>
</select>
<button class="btn" id="btn-spawn">Spawn</button>
</div>
<div id="spawn-opts"></div>
</div>
<div class="section">
<h3>Send Message</h3>
<div class="form-row">
<select id="send-target" style="flex:2"><option value="">— select actor —</option></select>
<input id="send-value" type="number" value="1" style="flex:1" placeholder="u32">
</div>
<button class="btn" id="btn-send" style="margin-top:4px">Send u32</button>
</div>
<div class="section">
<h3>Naming</h3>
<div class="form-row">
<input id="name-input" placeholder="name">
<select id="name-target" style="flex:1"><option value="">— actor —</option></select>
</div>
<div class="form-row">
<button class="btn small" id="btn-register">Register</button>
<button class="btn small" id="btn-lookup">Lookup</button>
<button class="btn small" id="btn-unreg">Unregister</button>
</div>
<div id="names-list" style="font-size:0.75em;color:var(--dim);margin-top:4px"></div>
</div>
<div class="section">
<h3>Groups</h3>
<div class="form-row">
<input id="group-name" placeholder="group name" value="workers">
</div>
<div class="form-row">
<button class="btn small" id="btn-broadcast">Broadcast 42</button>
</div>
<div id="groups-list" style="font-size:0.75em;color:var(--dim);margin-top:4px"></div>
</div>
</div>
<!-- CENTER: Actor Map + Visualization -->
<div class="panel">
<h2>Actors</h2>
<canvas id="viz" class="viz-canvas"></canvas>
<div id="actor-list" style="margin-top:12px"></div>
</div>
<!-- RIGHT: Event Log -->
<div class="panel">
<h2>Event Log</h2>
<div id="log"></div>
</div>
</div>
</div>
<script type="module">
import init, {
WasmRuntime, WasmAddr, WasmInboxU32, WasmInboxString,
spawn_counter, spawn_relay, spawn_sentinel, spawn_group_member,
} from './pkg-web/wasm.js';
await init();
document.getElementById('loading').style.display = 'none';
document.getElementById('app').style.display = 'block';
// ─── State ─────────────────────────────────────────────────────────────
const rt = new WasmRuntime();
const actors = new Map(); // id -> { addr, type, name?, groups:Set, inbox? }
let nextId = 1;
let running = false;
let tickCount = 0;
let rafId = null;
let lastTick = 0;
// Global inboxes for polling
const inboxes = []; // { inbox, type: 'u32'|'string', label, actorId? }
// ─── Logging ───────────────────────────────────────────────────────────
const logEl = document.getElementById('log');
function log(cls, msg) {
const t = new Date().toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 2 });
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.innerHTML = `<span class="log-time">${t}</span> <span class="${cls}">${msg}</span>`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
// cap at 500 entries
while (logEl.children.length > 500) logEl.removeChild(logEl.firstChild);
}
// ─── Spawn helpers ─────────────────────────────────────────────────────
function addActor(addr, type, extra = {}) {
const id = nextId++;
const entry = { id, addr, type, name: null, groups: new Set(), ...extra };
actors.set(id, entry);
log('log-spawn', `spawned <b>${type}</b> #${id} (${addr.toString()})`);
refreshActorUI();
return id;
}
function createInbox(type) {
if (type === 'u32') {
const inbox = rt.new_inbox_u32();
inboxes.push({ inbox, type: 'u32' });
return inbox;
} else if (type === 'string') {
const inbox = rt.new_inbox_string();
inboxes.push({ inbox, type: 'string' });
return inbox;
}
}
function doSpawn() {
const type = document.getElementById('spawn-type').value;
if (type === 'counter') {
const inbox = createInbox('u32');
const inboxAddr = inbox.addr();
const addr = spawn_counter(rt, inboxAddr);
const id = addActor(addr, 'Counter', { reportInbox: inbox });
} else if (type === 'relay') {
// relay needs a target — pick first available actor
const targetId = prompt('Target actor ID to relay to:');
const target = actors.get(Number(targetId));
if (!target) { log('log-death', 'invalid target'); return; }
const addr = spawn_relay(rt, target.addr);
addActor(addr, 'Relay', { relayTarget: targetId });
} else if (type === 'group_member') {
const group = document.getElementById('group-name').value || 'workers';
const inbox = createInbox('u32');
const inboxAddr = inbox.addr();
const addr = spawn_group_member(rt, group, inboxAddr);
const id = addActor(addr, 'GroupMember', { group, reportInbox: inbox });
actors.get(id).groups.add(group);
} else if (type === 'sentinel') {
const targetId = prompt('Actor ID to watch:');
const target = actors.get(Number(targetId));
if (!target) { log('log-death', 'invalid target'); return; }
const inbox = createInbox('string');
const addr = spawn_sentinel(rt, target.addr, inbox);
addActor(addr, 'Sentinel', { watching: targetId, deathInbox: inbox });
}
}
// ─── Tick loop ─────────────────────────────────────────────────────────
function tickOnce() {
rt.tick();
tickCount++;
pollInboxes();
updateStats();
}
function pollInboxes() {
for (const ib of inboxes) {
let val;
while ((val = ib.inbox.try_recv()) !== undefined) {
if (ib.type === 'u32') {
log('log-recv', `inbox received <b>${val}</b>`);
} else {
log('log-death', `death notification: <b>${val}</b>`);
}
}
}
}
function getTickInterval() {
return 1000 / Number(document.getElementById('speed').value);
}
function loop(ts) {
if (!running) return;
if (ts - lastTick >= getTickInterval()) {
tickOnce();
flashLed();
lastTick = ts;
}
rafId = requestAnimationFrame(loop);
}
function flashLed() {
const led = document.getElementById('tick-led');
led.classList.add('active');
setTimeout(() => led.classList.remove('active'), 80);
}
function updateStats() {
document.getElementById('s-actors').textContent = rt.actor_count();
document.getElementById('s-msgs').textContent = rt.total_messages();
document.getElementById('s-panics').textContent = rt.total_panics();
document.getElementById('s-uptime').textContent = Math.round(rt.uptime_ms()) + 'ms';
document.getElementById('s-ticks').textContent = tickCount;
// names
const names = rt.registered_names();
document.getElementById('names-list').textContent = names ? `Registered: ${names}` : 'No names registered';
// groups
const groups = rt.group_names();
const parts = groups ? groups.split(',').map(g => `${g}(${rt.group_member_count(g)})`).join(', ') : 'none';
document.getElementById('groups-list').textContent = `Groups: ${parts}`;
refreshDropdowns();
drawViz();
}
// ─── Actor Cards ───────────────────────────────────────────────────────
function refreshActorUI() {
const container = document.getElementById('actor-list');
container.innerHTML = '';
for (const [id, a] of actors) {
const card = document.createElement('div');
card.className = 'actor-card';
let meta = '';
if (a.name) meta += ` <span class="name-tag">@${a.name}</span>`;
if (a.groups.size) meta += ` <span class="group-tag">[${[...a.groups].join(',')}]</span>`;
if (a.relayTarget) meta += ` <span style="color:var(--dim)">→ #${a.relayTarget}</span>`;
if (a.watching) meta += ` <span style="color:var(--dim)">watching #${a.watching}</span>`;
card.innerHTML = `
<span class="type">${a.type}</span> <span style="color:var(--dim)">#${id}</span>${meta}
<br><span class="addr">${a.addr.toString()}</span>
<div class="actions">
<button class="btn small" onclick="window._send(${id})">Send 1</button>
<button class="btn small" onclick="window._send10(${id})">Send ×10</button>
<button class="btn small danger" onclick="window._stop(${id})">Stop</button>
</div>
`;
container.appendChild(card);
}
refreshDropdowns();
}
function refreshDropdowns() {
for (const sel of [document.getElementById('send-target'), document.getElementById('name-target')]) {
const prev = sel.value;
sel.innerHTML = '<option value="">— select —</option>';
for (const [id, a] of actors) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = `#${id} ${a.type}${a.name ? ' @' + a.name : ''}`;
sel.appendChild(opt);
}
sel.value = prev;
}
}
// ─── Visualization ─────────────────────────────────────────────────────
function drawViz() {
const canvas = document.getElementById('viz');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, rect.width, rect.height);
const entries = [...actors.values()];
if (entries.length === 0) {
ctx.fillStyle = '#8b949e';
ctx.font = '13px monospace';
ctx.textAlign = 'center';
ctx.fillText('Spawn some actors to see them here', rect.width / 2, rect.height / 2);
return;
}
const colors = { Counter: '#58a6ff', Relay: '#bc8cff', GroupMember: '#3fb950', Sentinel: '#f85149' };
const cx = rect.width / 2;
const cy = rect.height / 2;
const radius = Math.min(cx, cy) - 40;
// Position actors in a circle
const positions = new Map();
entries.forEach((a, i) => {
const angle = (2 * Math.PI * i) / entries.length - Math.PI / 2;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
positions.set(a.id, { x, y });
});
// Draw connections
ctx.lineWidth = 1;
for (const a of entries) {
const from = positions.get(a.id);
if (a.relayTarget && positions.has(Number(a.relayTarget))) {
const to = positions.get(Number(a.relayTarget));
ctx.strokeStyle = '#bc8cff44';
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();
drawArrow(ctx, from, to, '#bc8cff44');
}
if (a.watching && positions.has(Number(a.watching))) {
const to = positions.get(Number(a.watching));
ctx.strokeStyle = '#f8514944';
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();
ctx.setLineDash([]);
}
}
// Draw actors
for (const a of entries) {
const pos = positions.get(a.id);
const color = colors[a.type] || '#c9d1d9';
// glow
ctx.shadowColor = color;
ctx.shadowBlur = 12;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(pos.x, pos.y, 14, 0, 2 * Math.PI);
ctx.fill();
ctx.shadowBlur = 0;
// label
ctx.fillStyle = '#c9d1d9';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.fillText(`#${a.id}`, pos.x, pos.y + 4);
// type label below
ctx.fillStyle = '#8b949e';
ctx.font = '9px monospace';
ctx.fillText(a.type, pos.x, pos.y + 28);
if (a.name) {
ctx.fillStyle = '#d29922';
ctx.fillText(`@${a.name}`, pos.x, pos.y + 38);
}
}
}
function drawArrow(ctx, from, to, color) {
const dx = to.x - from.x, dy = to.y - from.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 30) return;
const ux = dx / len, uy = dy / len;
const tipX = to.x - ux * 18, tipY = to.y - uy * 18;
const sz = 6;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(tipX - ux * sz - uy * sz * 0.5, tipY - uy * sz + ux * sz * 0.5);
ctx.lineTo(tipX - ux * sz + uy * sz * 0.5, tipY - uy * sz - ux * sz * 0.5);
ctx.fill();
}
// ─── Global handlers (for inline onclick) ──────────────────────────────
window._send = (id) => {
const a = actors.get(id);
if (!a) return;
const val = Number(document.getElementById('send-value').value) || 1;
rt.send_u32(a.addr, val);
log('log-msg', `sent <b>${val}</b> → #${id} ${a.type}`);
};
window._send10 = (id) => {
const a = actors.get(id);
if (!a) return;
for (let i = 0; i < 10; i++) rt.send_u32(a.addr, 1);
log('log-msg', `sent <b>10×1</b> → #${id} ${a.type}`);
};
window._stop = (id) => {
const a = actors.get(id);
if (!a) return;
rt.stop_actor(a.addr);
log('log-death', `stopped #${id} ${a.type}`);
actors.delete(id);
refreshActorUI();
};
// ─── Button wiring ─────────────────────────────────────────────────────
document.getElementById('btn-toggle').addEventListener('click', () => {
running = !running;
document.getElementById('btn-toggle').textContent = running ? 'Pause' : 'Start';
if (running) { lastTick = performance.now(); rafId = requestAnimationFrame(loop); }
});
document.getElementById('btn-step').addEventListener('click', () => {
tickOnce();
flashLed();
});
document.getElementById('speed').addEventListener('input', (e) => {
document.getElementById('speed-label').textContent = e.target.value + ' tps';
});
document.getElementById('btn-spawn').addEventListener('click', doSpawn);
document.getElementById('btn-send').addEventListener('click', () => {
const id = Number(document.getElementById('send-target').value);
if (id) window._send(id);
});
document.getElementById('btn-register').addEventListener('click', () => {
const name = document.getElementById('name-input').value.trim();
const id = Number(document.getElementById('name-target').value);
const a = actors.get(id);
if (!name || !a) return;
const ok = rt.register_name(name, a.addr);
if (ok) {
a.name = name;
log('log-name', `registered <b>@${name}</b> → #${id}`);
refreshActorUI();
} else {
log('log-death', `name <b>@${name}</b> already taken`);
}
});
document.getElementById('btn-lookup').addEventListener('click', () => {
const name = document.getElementById('name-input').value.trim();
if (!name) return;
const found = rt.where_is(name);
if (found) {
log('log-name', `@${name} → ${found.toString()}`);
} else {
log('log-name', `@${name} not found`);
}
});
document.getElementById('btn-unreg').addEventListener('click', () => {
const name = document.getElementById('name-input').value.trim();
if (!name) return;
const prev = rt.unregister_name(name);
if (prev) {
for (const a of actors.values()) { if (a.name === name) a.name = null; }
log('log-name', `unregistered <b>@${name}</b>`);
refreshActorUI();
} else {
log('log-name', `@${name} was not registered`);
}
});
document.getElementById('btn-broadcast').addEventListener('click', () => {
const group = document.getElementById('group-name').value.trim();
if (!group) return;
const count = rt.publish_to_group_u32(group, 42);
log('log-group', `broadcast <b>42</b> to group "${group}" (${count} members)`);
});
// initial draw
updateStats();
log('log-spawn', 'runtime initialized — spawn some actors and hit <b>Start</b> or <b>Step</b>');
</script>
</body>
</html>

View file

@ -1,11 +1,240 @@
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorInterface};
use swactor::actor::{ActorAddress, ActorExited, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
use swactor_std::{CtxGroups, RuntimeNaming, RuntimeGroups, StdExtension};
// ---------------------------------------------------------------------------
// Actors (private — only exposed through the wasm API)
// ---------------------------------------------------------------------------
// ─── Core JS-facing types ───────────────────────────────────────────────────
/// Opaque actor address handle for JavaScript.
///
/// Returned by spawn functions, passed to send functions. JS never sees
/// the raw 32-byte address — it just holds and forwards this handle.
#[wasm_bindgen]
#[derive(Clone)]
pub struct WasmAddr(ActorAddress);
#[wasm_bindgen]
impl WasmAddr {
/// Debug representation of the address (first 8 hex bytes + ellipsis).
#[wasm_bindgen(js_name = toString)]
pub fn to_js_string(&self) -> String {
format!("{}", self.0)
}
}
impl WasmAddr {
/// Access the inner address from Rust (not exposed to JS).
pub fn inner(&self) -> ActorAddress {
self.0
}
}
/// Inbox that receives `u32` values from actors.
#[wasm_bindgen]
pub struct WasmInboxU32 {
inner: Inbox<u32>,
}
#[wasm_bindgen]
impl WasmInboxU32 {
/// The address actors should send results to.
pub fn addr(&self) -> WasmAddr {
WasmAddr(*self.inner.addr())
}
/// Poll for the next value. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<u32> {
self.inner.try_recv()
}
}
/// Inbox that receives byte arrays from actors.
#[wasm_bindgen]
pub struct WasmInboxBytes {
inner: Inbox<Vec<u8>>,
}
#[wasm_bindgen]
impl WasmInboxBytes {
pub fn addr(&self) -> WasmAddr {
WasmAddr(*self.inner.addr())
}
/// Poll for the next byte array. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<Vec<u8>> {
self.inner.try_recv()
}
}
/// Inbox that receives string values (used for death notifications, etc.).
#[wasm_bindgen]
pub struct WasmInboxString {
inner: Inbox<String>,
}
#[wasm_bindgen]
impl WasmInboxString {
pub fn addr(&self) -> WasmAddr {
WasmAddr(*self.inner.addr())
}
/// Poll for the next string. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<String> {
self.inner.try_recv()
}
}
// ─── Runtime ────────────────────────────────────────────────────────────────
/// The browser-facing swactor runtime.
///
/// Wraps `swactor::Runtime` in single-threaded mode with StdExtension installed
/// (naming, monitoring, groups). Actors are spawned via dedicated spawn functions
/// (one per actor type). The runtime is driven by calling `tick()`.
#[wasm_bindgen]
pub struct WasmRuntime {
rt: Runtime,
}
#[wasm_bindgen]
impl WasmRuntime {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
})
.with_extension(Arc::new(StdExtension::new()));
Self { rt }
}
/// Drive one tick of the runtime.
pub fn tick(&self) {
self.rt.tick();
}
/// Number of actors currently alive.
pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len()
}
/// Create an inbox that receives u32 values.
pub fn new_inbox_u32(&self) -> WasmInboxU32 {
WasmInboxU32 {
inner: self.rt.new_inbox().expect("new_inbox_u32"),
}
}
/// Create an inbox that receives byte arrays.
pub fn new_inbox_bytes(&self) -> WasmInboxBytes {
WasmInboxBytes {
inner: self.rt.new_inbox().expect("new_inbox_bytes"),
}
}
/// Create an inbox that receives strings.
pub fn new_inbox_string(&self) -> WasmInboxString {
WasmInboxString {
inner: self.rt.new_inbox().expect("new_inbox_string"),
}
}
/// Send a u32 to an actor. Returns false if the address is invalid.
pub fn send_u32(&self, addr: &WasmAddr, value: u32) -> bool {
self.rt.send_to(addr.0, value).is_ok()
}
/// Send a byte array to an actor. Returns false if the address is invalid.
pub fn send_bytes(&self, addr: &WasmAddr, data: &[u8]) -> bool {
self.rt.send_to(addr.0, data.to_vec()).is_ok()
}
/// Stop an actor gracefully.
pub fn stop_actor(&self, addr: &WasmAddr) -> bool {
self.rt.stop_actor(addr.0).is_ok()
}
/// Runtime uptime in milliseconds.
pub fn uptime_ms(&self) -> f64 {
self.rt.stats().uptime_ms as f64
}
// ─── Naming ─────────────────────────────────────────────────────────────
/// Register a name for an actor address. Returns false if the name is taken.
pub fn register_name(&self, name: &str, addr: &WasmAddr) -> bool {
self.rt.register_name(name.to_string(), addr.0).is_ok()
}
/// Look up an actor address by name. Returns undefined if not found.
pub fn where_is(&self, name: &str) -> Option<WasmAddr> {
self.rt.where_is(name).map(WasmAddr)
}
/// Unregister a name. Returns the address it was bound to, or undefined.
pub fn unregister_name(&self, name: &str) -> Option<WasmAddr> {
self.rt.unregister(name).map(WasmAddr)
}
/// Return all registered actor names as a comma-separated string.
pub fn registered_names(&self) -> String {
self.rt.registered_names().join(",")
}
// ─── Groups ─────────────────────────────────────────────────────────────
/// Add an actor to a named group.
pub fn join_group(&self, addr: &WasmAddr, group: &str) {
self.rt.join_group(addr.0, group.to_string());
}
/// Remove an actor from a named group.
pub fn leave_group(&self, addr: &WasmAddr, group: &str) {
self.rt.leave_group(addr.0, group);
}
/// Broadcast a u32 message to all members of a group. Returns count sent.
pub fn publish_to_group_u32(&self, group: &str, msg: u32) -> usize {
self.rt.publish_to(group, msg)
}
/// Number of actors in a group.
pub fn group_member_count(&self, group: &str) -> usize {
self.rt.group_members(group).len()
}
/// Return all group names as a comma-separated string.
pub fn group_names(&self) -> String {
self.rt.groups().join(",")
}
// ─── Stats ──────────────────────────────────────────────────────────────
/// Total messages processed across all workers.
pub fn total_messages(&self) -> f64 {
self.rt.stats().workers.iter().map(|w| w.messages_processed).sum::<u64>() as f64
}
/// Total panics across all workers.
pub fn total_panics(&self) -> f64 {
self.rt.stats().workers.iter().map(|w| w.panics).sum::<u64>() as f64
}
}
impl WasmRuntime {
/// Access the inner Runtime from Rust (for custom spawn functions).
pub fn runtime(&self) -> &Runtime {
&self.rt
}
}
// ─── Demo actors ────────────────────────────────────────────────────────────
//
// These demonstrate the pattern for exposing actors to JavaScript.
// Each actor type gets a `spawn_*` function that returns a WasmAddr.
struct Counter {
total: u32,
@ -35,79 +264,100 @@ impl ActorInterface for Relay {
}
}
// ---------------------------------------------------------------------------
// JS-facing runtime wrapper
// ---------------------------------------------------------------------------
#[wasm_bindgen]
pub struct SwactorRuntime {
rt: Runtime,
inbox: Inbox<u32>,
actors: Vec<ActorAddress>,
/// A sentinel actor that watches a target and reports its death to an inbox.
///
/// Uses the std monitoring extension (CtxMonitoring::monitor). When the target
/// dies, the sentinel receives a `Down` message and sends the dead actor's
/// string representation to the report inbox, then stops itself.
struct Sentinel {
target: ActorAddress,
report_to: ActorAddress,
}
#[wasm_bindgen]
impl SwactorRuntime {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
});
let inbox = rt.new_inbox().unwrap();
Self {
rt,
inbox,
actors: Vec::new(),
}
impl ActorInterface for Sentinel {
type Incoming = ();
type Response = ();
fn handle(&mut self, _ctx: &Ctx, _msg: ()) {}
fn on_start(&mut self, ctx: &Ctx) {
ctx.watch(self.target);
}
/// Spawn a counter actor. Returns its index (used with `send`).
pub fn spawn_counter(&mut self) -> usize {
let addr = self
fn on_actor_exit(&mut self, ctx: &Ctx, exited: ActorExited) {
let msg = format!("{}:{:?}", exited.addr, exited.reason);
let _ = ctx.send(self.report_to, msg);
ctx.stop_self();
}
}
/// A group member that joins a named group and forwards u32 messages to a report inbox.
struct GroupMember {
group: String,
report_to: ActorAddress,
}
impl ActorInterface for GroupMember {
type Incoming = u32;
type Response = ();
fn on_start(&mut self, ctx: &Ctx) {
ctx.join_group(self.group.clone());
}
fn handle(&mut self, ctx: &Ctx, msg: u32) {
let _ = ctx.send(self.report_to, msg);
}
}
/// Spawn a counter that accumulates u32 values and reports running totals
/// to the given inbox address.
#[wasm_bindgen]
pub fn spawn_counter(rt: &WasmRuntime, report_to: &WasmAddr) -> WasmAddr {
let addr = rt
.rt
.spawn(Counter {
total: 0,
report_to: *self.inbox.addr(),
report_to: report_to.0,
})
.expect("spawn counter");
let idx = self.actors.len();
self.actors.push(addr);
idx
}
/// Spawn a relay that forwards every message to `target_idx`.
pub fn spawn_relay(&mut self, target_idx: usize) -> usize {
let target = self.actors[target_idx];
let addr = self
.rt
.spawn(Relay { target })
.expect("spawn relay");
let idx = self.actors.len();
self.actors.push(addr);
idx
}
/// Send a u32 to the actor at `actor_idx`.
pub fn send(&self, actor_idx: usize, value: u32) -> bool {
if actor_idx >= self.actors.len() {
return false;
}
self.rt.send_to(self.actors[actor_idx], value).is_ok()
}
/// Drive one tick of the single-threaded runtime.
pub fn tick(&self) {
self.rt.tick();
}
/// Try to read the next result from the inbox. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<u32> {
self.inbox.try_recv()
}
/// Number of actors the runtime knows about.
pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len()
}
WasmAddr(addr)
}
/// Spawn a relay that forwards every u32 message to the target actor.
#[wasm_bindgen]
pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr {
let addr = rt
.rt
.spawn(Relay { target: target.0 })
.expect("spawn relay");
WasmAddr(addr)
}
/// Spawn a sentinel that watches a target actor and reports its death
/// to the given string inbox.
#[wasm_bindgen]
pub fn spawn_sentinel(rt: &WasmRuntime, target: &WasmAddr, report_to: &WasmInboxString) -> WasmAddr {
let addr = rt
.rt
.spawn(Sentinel {
target: target.0,
report_to: *report_to.inner.addr(),
})
.expect("spawn sentinel");
WasmAddr(addr)
}
/// Spawn a group member that joins the given group and forwards u32 messages
/// to the report inbox.
#[wasm_bindgen]
pub fn spawn_group_member(rt: &WasmRuntime, group: &str, report_to: &WasmAddr) -> WasmAddr {
let addr = rt
.rt
.spawn(GroupMember {
group: group.to_string(),
report_to: report_to.0,
})
.expect("spawn group_member");
WasmAddr(addr)
}

View file

@ -1,4 +1,11 @@
import { SwactorRuntime } from "./pkg/swactor_wasm.js";
import {
WasmRuntime,
WasmAddr,
spawn_counter,
spawn_relay,
spawn_sentinel,
spawn_group_member,
} from "./pkg/wasm.js";
let passed = 0;
let failed = 0;
@ -23,77 +30,314 @@ function assertEq(a, b, msg) {
}
}
function drain(rt) {
function drainInbox(inbox) {
const results = [];
let v;
while ((v = rt.try_recv()) !== undefined) results.push(v);
while ((v = inbox.try_recv()) !== undefined) results.push(v);
return results;
}
// ---- accumulator ----------------------------------------------------------
{
console.log("test: accumulator processes messages");
const rt = new SwactorRuntime();
const c = rt.spawn_counter();
rt.send(c, 1);
rt.send(c, 2);
rt.send(c, 10);
console.log("test: accumulator processes messages via WasmAddr");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.send_u32(c, 1);
rt.send_u32(c, 2);
rt.send_u32(c, 10);
rt.tick();
assertEq(drain(rt), [1, 3, 13], "running totals");
assertEq(drainInbox(inbox), [1, 3, 13], "running totals");
inbox.free();
rt.free();
}
// ---- relay ----------------------------------------------------------------
{
console.log("test: relay forwards to counter");
const rt = new SwactorRuntime();
const c = rt.spawn_counter();
const r = rt.spawn_relay(c);
rt.send(r, 5);
rt.send(r, 7);
// tick 1: relay receives and forwards (cross-actor, same worker → pending_local)
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
const r = spawn_relay(rt, c);
rt.send_u32(r, 5);
rt.send_u32(r, 7);
// tick 1: relay receives and forwards (same worker → pending_local)
// tick 2: counter receives forwarded messages
rt.tick();
rt.tick();
assertEq(drain(rt), [5, 12], "relayed totals");
assertEq(drainInbox(inbox), [5, 12], "relayed totals");
inbox.free();
rt.free();
}
// ---- multiple counters ----------------------------------------------------
{
console.log("test: multiple independent counters");
const rt = new SwactorRuntime();
const a = rt.spawn_counter();
const b = rt.spawn_counter();
rt.send(a, 10);
rt.send(b, 100);
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const a = spawn_counter(rt, inbox.addr());
const b = spawn_counter(rt, inbox.addr());
rt.send_u32(a, 10);
rt.send_u32(b, 100);
rt.tick();
const results = drain(rt);
// order depends on HashMap iteration, so just check set equality
const results = drainInbox(inbox);
assert(
results.includes(10) && results.includes(100) && results.length === 2,
"both counters report"
);
inbox.free();
rt.free();
}
// ---- actor_count ----------------------------------------------------------
{
console.log("test: actor_count tracks spawns");
const rt = new SwactorRuntime();
rt.spawn_counter();
rt.spawn_counter();
rt.spawn_counter();
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
spawn_counter(rt, inbox.addr());
spawn_counter(rt, inbox.addr());
spawn_counter(rt, inbox.addr());
rt.tick(); // drain spawn queue
assertEq(rt.actor_count(), 3, "three actors");
inbox.free();
rt.free();
}
// ---- send to invalid index returns false ----------------------------------
// ---- WasmAddr toString ----------------------------------------------------
{
console.log("test: send to bad index returns false");
const rt = new SwactorRuntime();
assert(!rt.send(999, 1), "out-of-bounds send");
console.log("test: WasmAddr has string representation");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const addr = spawn_counter(rt, inbox.addr());
const s = addr.toString();
// no_random generates deterministic addresses — just check it's a non-empty hex string
assert(typeof s === "string" && s.length > 0, "addr toString is non-empty string");
inbox.free();
rt.free();
}
// ---- stop_actor -----------------------------------------------------------
{
console.log("test: stop_actor removes actor");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.tick(); // drain spawn
assertEq(rt.actor_count(), 1, "one actor before stop");
rt.stop_actor(c);
rt.tick(); // process stop + cleanup
assertEq(rt.actor_count(), 0, "zero actors after stop");
inbox.free();
rt.free();
}
// ---- bytes inbox ----------------------------------------------------------
{
console.log("test: byte inbox receives Uint8Array");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_bytes();
// Send bytes directly (no actor — just to the inbox address)
rt.send_bytes(inbox.addr(), new Uint8Array([1, 2, 3]));
rt.tick();
const result = inbox.try_recv();
assert(result instanceof Uint8Array, "result is Uint8Array");
assertEq(Array.from(result), [1, 2, 3], "bytes match");
inbox.free();
rt.free();
}
// ---- uptime ---------------------------------------------------------------
{
console.log("test: uptime_ms returns a number");
const rt = new WasmRuntime();
const uptime = rt.uptime_ms();
assert(typeof uptime === "number" && uptime >= 0, "uptime is non-negative number");
rt.free();
}
// ---- naming: register and resolve -----------------------------------------
{
console.log("test: naming — register_name and where_is");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.tick(); // drain spawn
const ok = rt.register_name("my_counter", c);
assert(ok, "register_name succeeds");
const found = rt.where_is("my_counter");
assert(found !== undefined, "where_is finds registered actor");
assertEq(found.toString(), c.toString(), "where_is returns correct address");
const notFound = rt.where_is("nonexistent");
assert(notFound === undefined, "where_is returns undefined for unknown name");
found.free();
inbox.free();
rt.free();
}
// ---- naming: unregister ---------------------------------------------------
{
console.log("test: naming — unregister_name");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.tick();
rt.register_name("temp", c);
const prev = rt.unregister_name("temp");
assert(prev !== undefined, "unregister returns previous address");
assertEq(prev.toString(), c.toString(), "unregister returns correct address");
const gone = rt.where_is("temp");
assert(gone === undefined, "name no longer resolves after unregister");
prev.free();
inbox.free();
rt.free();
}
// ---- naming: registered_names ---------------------------------------------
{
console.log("test: naming — registered_names");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const a = spawn_counter(rt, inbox.addr());
const b = spawn_counter(rt, inbox.addr());
rt.tick();
rt.register_name("alpha", a);
rt.register_name("beta", b);
const names = rt.registered_names().split(",").sort();
assertEq(names, ["alpha", "beta"], "registered_names lists all names");
inbox.free();
rt.free();
}
// ---- naming: duplicate name rejected --------------------------------------
{
console.log("test: naming — duplicate name rejected");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const a = spawn_counter(rt, inbox.addr());
const b = spawn_counter(rt, inbox.addr());
rt.tick();
const ok1 = rt.register_name("unique", a);
const ok2 = rt.register_name("unique", b);
assert(ok1, "first registration succeeds");
assert(!ok2, "duplicate registration fails");
inbox.free();
rt.free();
}
// ---- groups: join and broadcast -------------------------------------------
{
console.log("test: groups — join_group and publish_to_group_u32");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const a = spawn_group_member(rt, "workers", inbox.addr());
const b = spawn_group_member(rt, "workers", inbox.addr());
rt.tick(); // spawn + on_start (join group)
assertEq(rt.group_member_count("workers"), 2, "two members in group");
rt.publish_to_group_u32("workers", 42);
rt.tick(); // group members receive
rt.tick(); // group members forward to inbox
const results = drainInbox(inbox);
assertEq(results.length, 2, "both members received broadcast");
assert(results.every((v) => v === 42), "correct value broadcast");
inbox.free();
rt.free();
}
// ---- groups: leave --------------------------------------------------------
{
console.log("test: groups — leave_group");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const a = spawn_group_member(rt, "pool", inbox.addr());
const b = spawn_group_member(rt, "pool", inbox.addr());
rt.tick(); // spawn + on_start
assertEq(rt.group_member_count("pool"), 2, "two members before leave");
rt.leave_group(a, "pool");
assertEq(rt.group_member_count("pool"), 1, "one member after leave");
inbox.free();
rt.free();
}
// ---- groups: group_names --------------------------------------------------
{
console.log("test: groups — group_names");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
spawn_group_member(rt, "alpha", inbox.addr());
spawn_group_member(rt, "beta", inbox.addr());
rt.tick(); // spawn + join
const names = rt.group_names().split(",").sort();
assertEq(names, ["alpha", "beta"], "group_names lists all groups");
inbox.free();
rt.free();
}
// ---- watching: sentinel detects death -------------------------------------
{
console.log("test: watching — sentinel reports actor death");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const deathInbox = rt.new_inbox_string();
const target = spawn_counter(rt, inbox.addr());
const sentinel = spawn_sentinel(rt, target, deathInbox);
rt.tick(); // spawn + on_start (watch)
rt.stop_actor(target);
// tick to process stop, cleanup, and deliver death notification
for (let i = 0; i < 5; i++) rt.tick();
const notification = deathInbox.try_recv();
assert(notification !== undefined, "sentinel received death notification");
assert(
typeof notification === "string" && notification.length > 0,
"notification is a non-empty string"
);
inbox.free();
deathInbox.free();
rt.free();
}
// ---- stats: total_messages ------------------------------------------------
{
console.log("test: stats — total_messages");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.send_u32(c, 1);
rt.send_u32(c, 2);
rt.send_u32(c, 3);
rt.tick();
assert(rt.total_messages() >= 3, "total_messages counts processed messages");
inbox.free();
rt.free();
}
// ---- stats: total_panics starts at zero -----------------------------------
{
console.log("test: stats — total_panics starts at zero");
const rt = new WasmRuntime();
assertEq(rt.total_panics(), 0, "no panics initially");
rt.free();
}

View file

@ -0,0 +1,69 @@
# Browser Runtime API — Development History
> Stage 2 of the in-browser swactor runtime. Replaces the hardcoded PoC with
> a generic, type-safe API using opaque address handles and typed inboxes.
---
## Changes
### Core Types
**`WasmRuntime`** — wraps `swactor::Runtime` in single-threaded mode.
Methods: `tick()`, `actor_count()`, `send_u32()`, `send_bytes()`,
`stop_actor()`, `uptime_ms()`, `new_inbox_u32()`, `new_inbox_bytes()`.
Also exposes `runtime()` for Rust-side custom spawn functions.
**`WasmAddr`** — opaque handle wrapping `ActorAddress`. Returned by spawn
functions, passed to send functions. JS holds it as an opaque object.
Has `toString()` for debugging.
**`WasmInboxU32`** / **`WasmInboxBytes`** — typed inboxes for receiving
results from actors. Each has `addr()` → `WasmAddr` (so actors know where
to send) and `try_recv()` → `Option<T>`.
### Design Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Opaque `WasmAddr` handles instead of indices | Type-safe, stable identity, no out-of-bounds errors |
| 2 | Typed inbox types instead of generic `Inbox<T>` | wasm-bindgen doesn't support generics; concrete types are explicit |
| 3 | Free-standing `spawn_*` functions, not methods | Each actor type gets its own spawn function with typed args |
| 4 | `send_u32`/`send_bytes` on runtime | Common send types; custom types use typed spawn wrappers |
| 5 | Evolved existing `crates/wasm/` instead of new crate | Less churn, existing build/test infrastructure |
### Actor Pattern
Users expose actors to JS by writing one `#[wasm_bindgen]` spawn function
per actor type:
```rust
#[wasm_bindgen]
pub fn spawn_my_actor(rt: &WasmRuntime, arg: JsValue) -> WasmAddr {
let actor = MyActor::from_js(arg);
let addr = rt.runtime().spawn(actor).unwrap();
WasmAddr(addr)
}
```
## Test Coverage
10 Node.js tests in `crates/wasm/test.mjs`:
| Test | Scenario |
|------|----------|
| accumulator | Counter processes messages, reports running totals to inbox |
| relay | Relay forwards messages to counter (cross-actor, 2 ticks) |
| multiple counters | Two independent counters report to same inbox |
| actor_count | Spawning 3 actors reflects in stats |
| WasmAddr toString | Address has non-empty debug representation |
| stop_actor | Graceful stop removes actor from runtime |
| bytes inbox | WasmInboxBytes receives Uint8Array correctly |
| uptime_ms | Returns non-negative number |
## Verification
- `cargo test -p swactor` — native tests pass (no regressions)
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
- `node test.mjs` in `crates/wasm/` — 10/10 tests pass

View file

@ -0,0 +1,60 @@
# Stage 5 — Interactive Browser Demo
Visual verification page for the in-browser swactor runtime. Single self-contained
HTML file that loads the `--target web` wasm build and exposes every API surface
through a live dashboard.
## Running
```bash
# Build for browser (one-time, or after Rust changes)
cd crates/wasm && wasm-pack build --target web --out-dir pkg-web
# Serve (any static server works — needs correct .wasm MIME type)
cd crates/wasm && python3 -m http.server 8080
```
Open `http://localhost:8080/demo.html`.
## What It Covers
| Feature | How to verify |
|---|---|
| Runtime tick loop | Start/Pause button, Step for single tick, adjustable 1–60 tps |
| Actor spawning | Spawn Counter, Relay, GroupMember, Sentinel from dropdown |
| Message delivery | Send u32 to any actor, inbox polling shows received values |
| Cross-actor relay | Spawn Relay → target Counter, send to relay, counter accumulates |
| Actor stopping | Stop button on each card, actor disappears from viz |
| Watching / death notifications | Spawn Sentinel watching an actor, stop the watched actor |
| Name registry | Register/Lookup/Unregister names, live list in sidebar |
| Groups | GroupMember auto-joins on spawn, Broadcast sends to all members |
| Stats | Live actor count, total messages, total panics, uptime, tick count |
## Architecture
```
demo.html
├── imports pkg-web/wasm.js (ES module, --target web)
├── creates WasmRuntime (single-threaded, StdExtension)
├── requestAnimationFrame tick loop
├── canvas visualization (actor circle graph + edges)
└── event log (spawn, send, recv, death, naming, groups)
```
All state lives in the page. No build step, no bundler, no framework — just
the wasm module and vanilla JS.
## Suggested Walkthrough
1. **Counter basics** — Spawn a Counter, Step once, click "Send 1", Step again.
Inbox log shows the running total.
2. **Relay chain** — Spawn Counter #1, then Relay targeting #1. Send to the relay,
observe the counter accumulating.
3. **Death watching** — Spawn a Counter, then a Sentinel watching it. Stop the
counter. The sentinel reports the death and self-terminates.
4. **Groups** — Spawn 3 GroupMembers in "workers". Hit "Broadcast 42". All three
receive the message.
5. **Naming** — Register "@main" for an actor. Lookup confirms it resolves. Unregister
and verify it's gone.
6. **Burst load** — Spawn several counters, click "Send ×10" on each, start the
runtime at 60 tps. Watch messages processed climb.

View file

@ -0,0 +1,75 @@
# Feature Parity — Development History
> Stage 4 of the in-browser swactor runtime. Enables swactor-std extensions
> (naming, monitoring, groups) and core actor watching in the wasm crate.
---
## Changes
### swactor-std wasm compilation
- Added `wasm` feature to `crates/std/Cargo.toml` (forwards to `swactor/wasm`)
- Changed swactor dependency to `default-features = false`, forwarding `getrandom`
feature when active (`getrandom = ["dep:getrandom", "swactor/getrandom"]`)
- Cfg-gated `getrandom::getrandom()` call in `router.rs` `RoutingStrategy::Random`
— falls back to round-robin when `getrandom` feature is disabled (wasm mode)
### RuntimeNaming: register_name
- Added `register_name(name, addr)` method to `RuntimeNaming` trait and impl
— allows registering a name for an already-spawned actor from outside the runtime
— complements existing `spawn_named` (which spawns + registers atomically)
### Core watching fix: StopSignal death notifications
- Fixed gap in `worker.rs` tick_all: externally-stopped actors (via `rt.stop_actor()`)
were not added to the `deaths` list, so core WatchRegistry (phase 5b) never fired
for them. Added `deaths.push((addr, ExitReason::Stopped))` when StopSignal is
intercepted (line 737). All 140 existing native tests continue to pass.
### WasmRuntime: StdExtension + new APIs
- `WasmRuntime::new()` now installs `StdExtension` automatically
- New inbox type: `WasmInboxString` for receiving string notifications
- **Naming API**: `register_name`, `where_is`, `unregister_name`, `registered_names`
- **Groups API**: `join_group`, `leave_group`, `publish_to_group_u32`,
`group_member_count`, `group_names`
- **Stats API**: `total_messages`, `total_panics` (returned as f64 for JS compat)
- New demo actors:
- `Sentinel` — watches a target via `ctx.watch()`, reports death to string inbox
- `GroupMember` — joins a group on start, forwards u32 messages to report inbox
### Design Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | StdExtension always installed | Browser runtime should have full naming/groups by default |
| 2 | Stats as f64, not u64 | wasm-bindgen maps u64 to BigInt which JSON.stringify rejects |
| 3 | Sentinel actor for watching | Demonstrates core watching from JS without exposing Watch API directly |
| 4 | register_name on RuntimeNaming | Needed for post-spawn registration from JS (no actor context available) |
| 5 | Round-robin fallback for Random routing | wasm mode disables getrandom; graceful degradation preferred |
## Test Coverage
22 new assertions across 10 new test scenarios (30 total, from 10):
| Test | Scenario |
|------|----------|
| naming — register_name and where_is | Register name, resolve, verify not-found returns undefined |
| naming — unregister_name | Unregister returns previous addr, name no longer resolves |
| naming — registered_names | Lists all registered names as CSV |
| naming — duplicate name rejected | Second registration with same name fails |
| groups — join_group and publish_to_group_u32 | Two members receive broadcast message |
| groups — leave_group | Member count decreases after leave |
| groups — group_names | Lists all active group names |
| watching — sentinel reports actor death | Stop target → sentinel receives death notification |
| stats — total_messages | Counts processed messages across workers |
| stats — total_panics starts at zero | Fresh runtime has zero panics |
## Verification
- `cargo test -p swactor -p swactor-std` — 157 native tests pass (no regressions)
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
- `node test.mjs` in `crates/wasm/` — 30/30 tests pass

View file

@ -0,0 +1,82 @@
# Platform Abstraction Layer — Development History
> Stage 1 of the in-browser swactor runtime. Makes core swactor compile for
> `wasm32-unknown-unknown` without behavioral changes on native targets.
---
## Changes
### 1. `web-time` dependency + `wasm` feature flag
**File**: `Cargo.toml`
Added `web-time` as an optional dependency and a `wasm` feature that bundles
`no_random` + `web-time`:
```toml
wasm = ["no_random", "dep:web-time"]
web-time = { version = "0.2", optional = true }
```
`web-time` is a drop-in replacement for `std::time::Instant`:
- Native: re-exports `std::time::Instant` (zero-cost)
- wasm32: uses `performance.now()` via `js-sys`
### 2. Platform-aware `Instant` re-export
**File**: `src/lib.rs`
```rust
#[cfg(feature = "wasm")]
pub(crate) use web_time::Instant;
#[cfg(not(feature = "wasm"))]
pub(crate) use std::time::Instant;
```
All modules (`runtime.rs`, `worker.rs`) now use `crate::Instant` instead of
`std::time::Instant`. Single point of truth — no cfg noise in consumer code.
### 3. cfg-gated `Runtime::run()` and `RuntimeHandle`
**File**: `src/runtime.rs`
`Runtime::run()` calls `std::thread::spawn()` which is not available on wasm32.
Both `run()` and `RuntimeHandle` (which holds `JoinHandle<()>`) are gated:
```rust
#[cfg(not(target_arch = "wasm32"))]
pub fn run(self) -> Result<RuntimeHandle, Error> { ... }
```
On wasm32, the browser crate will provide its own `run()` via Web Workers.
`tick()` remains available on all platforms for single-threaded driving.
### 4. Updated `crates/wasm/` to use `wasm` feature
**File**: `crates/wasm/Cargo.toml`
Changed from `features = ["no_random"]` to `features = ["wasm"]` to pick up
the `web-time` Instant on wasm32.
## What Did NOT Need Abstraction
Key discovery: on wasm32 with the `+atomics` target feature, most of
`std::sync` and `std::thread` works:
- `OnceLock<Thread>` — compiles and works (futex-based)
- `Thread::unpark()` — works (futex → `memory.atomic.notify`)
- `thread::park_timeout()` — works (futex → `memory.atomic.wait32`)
- `thread::yield_now()` — works (no-op on wasm)
- `Mutex`, `RwLock` — work (futex-based)
- `crossbeam-queue` — works (uses `core::sync::atomic`)
- `AtomicBool/Usize/U64` — work (wasm atomic instructions)
Only `std::thread::spawn()` and `JoinHandle` are not functional on wasm32.
## Verification
- `cargo test` — all native tests pass (no regressions)
- `cargo test --features wasm` — all native tests pass with wasm feature
- `cargo build --target wasm32-unknown-unknown --features wasm --no-default-features` — compiles
- `cargo build --target wasm32-unknown-unknown -p wasm` — existing PoC crate compiles

View file

@ -18,6 +18,13 @@ pub mod runtime;
#[cfg(feature = "transport")]
pub mod transport;
// Platform-aware Instant: web_time on wasm, std::time on native.
// web_time is a no-op re-export of std::time::Instant on non-wasm targets.
#[cfg(feature = "wasm")]
pub(crate) use web_time::Instant;
#[cfg(not(feature = "wasm"))]
pub(crate) use std::time::Instant;
#[cfg(feature = "getrandom")]
pub(crate) fn get_random(buf: &mut [u8]) {
getrandom::getrandom(buf).unwrap()

View file

@ -2,8 +2,10 @@ use std::any::Any;
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread::{self, JoinHandle, Thread};
use std::time::Instant;
#[cfg(not(target_arch = "wasm32"))]
use std::thread::{self, JoinHandle};
use std::thread::Thread;
use crate::Instant;
use crate::actor::{Actor, ActorAddress, ActorExited, ActorInterface, AnyActor, ExitReason, Message, StopSignal, TimerRequest};
use crate::channel::{Receiver, Sender};
@ -67,11 +69,13 @@ impl<R: Message> Ask<R> {
}
/// Handle for dealing with a runtime that has started via the `Runtime::run()` method.
#[cfg(not(target_arch = "wasm32"))]
pub struct RuntimeHandle {
pub runtime: Arc<Runtime>,
threads: Vec<JoinHandle<()>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl RuntimeHandle {
pub fn join(self) {
for handle in self.threads {
@ -333,6 +337,9 @@ impl Runtime {
///
/// Works in both single-threaded and multi-threaded configurations.
/// In single-threaded mode, one background thread is spawned.
///
/// Not available on wasm32 — use the browser crate's Web Worker-based run instead.
#[cfg(not(target_arch = "wasm32"))]
pub fn run(self) -> Result<RuntimeHandle, Error> {
self.is_running.store(true, Ordering::Release);

View file

@ -2,9 +2,9 @@ use std::any::Any;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::thread;
use std::time::Instant;
use crate::Instant;
use crate::actor::{ActorAddress, ActorExited, AnyActor, CloneMsg, ContextInner, Ctx, ExitReason, StopReason, StopSignal, TimerRequest};
use crate::channel::Receiver;
@ -735,6 +735,7 @@ impl ActorPool {
slot.stopping = true;
stats.stops.fetch_add(1, Ordering::Relaxed);
slot.mailbox.clear();
deaths.push((addr, ExitReason::Stopped));
#[cfg(feature = "tracing")]
tracing::info!(actor_addr = %addr, "actor.stop_requested");
break;