Turn every device on your network into one distributed supercomputer. Any phone, laptop, or desktop can lend its spare CPU cycles to a shared pool of compute — a phone joins by opening a web page, a laptop joins by running one command — and Cloudlet splits real work across all of them.
┌─────────────────────┐
submit a job ───▶ │ coordinator │ ◀──── dashboard (live leaderboard)
(any parallel task) │ job queue + scheduler │
│ + benchmark + rank │
└───────────┬──────────┘
WebSocket (ws://.../ws)
┌────────────────────┼────────────────────┐
▼ ▼ ▼
laptop (CLI agent) phone/tablet (browser) desktop (CLI agent)
thread pool, 1 Web Worker pool thread pool, 1
slot per core (up to 4 slots) slot per core
- Coordinator (
server/) — a small Node/Express + WebSocket server. It holds the job queue, benchmarks and ranks every device as it joins (see below), and hands out tasks to whichever device has a free slot ("pull" scheduling), so fast/idle devices naturally do more of the job and nothing needs manual load balancing. - Browser worker (
worker-browser/) — a page any device opens in its browser (/join). No install. It runs assigned tasks across a small pool of dedicated Web Worker threads and reports results back over the same WebSocket. This is how phones and tablets join. - CLI agent (
worker-cli/agent.js) — a small Node process for laptops and desktops you want contributing persistently in the background. It's one WebSocket connection (one "device") backed by a pool of Nodeworker_threads— one per CPU core by default — so a single agent uses the whole machine and still shows up as one entry in the leaderboard. - Jobs are generic. A job is just "run this against each item in this
array, somewhere in the swarm" — either a plain JS function (any device),
or a real Docker container built from your own Dockerfile (CLI-agent
devices with Docker only — see "Running containers on the cluster"
below). Cloudlet doesn't know or care what the work itself does — see
demo/for three working examples (Mandelbrot rendering, prime counting, a containerized Python job), or write your own.
Every device is benchmarked automatically the moment it joins, and ranked against the rest of the swarm — a phone reporting 2 weak cores lands at the bottom, a desktop reporting 16 fast cores lands at the top, with no manual scoring needed:
- Self-reported specs. On connect, a device reports its logical core
count (
cores, which also sets how many task "slots" it gets), an approximate memory size (memoryGB), and a GPU name if one can be detected (gpu). These are shown on the leaderboard as context but do not by themselves affect the score below — self-reported numbers are easy to spoof, so nothing structural depends on them being honest except how many task slots that device is trusted with. - Measured benchmark. Right after registering, the coordinator runs a
small fixed workload on the device twice, through the exact same
code path real jobs use (a discarded warm-up pass, then a scored pass) —
see
server/benchmark.js. The result is a CCU (Cloudlet Compute Unit) score: benchmark iterations completed per millisecond, on that one device, for real. It's a made-up unit, not an industry benchmark — it's only meaningful relative to other devices that ran the same function. - Aggregate score & tier.
aggregateCcu = ccu-per-core × slotsestimates that device's total contribution to the swarm (a desktop with 8 average cores beats a phone with 1 fast core). That number sorts the leaderboard and buckets each device into a tier: 🪫 Modest, ⚙️ Capable, 🚀 Strong, ⚡ Elite (thresholds inserver/benchmark.js— they're rough, empirically-set starting points, expect to retune them as more real device data comes in).
Worth knowing: the benchmark measures real achievable throughput through
Cloudlet on that platform, which is what actually matters for scheduling —
but that means a browser worker (plain JS in a Worker) and a CLI agent
(sandboxed via Node's vm module) aren't perfectly apples-to-apples on
identical hardware, because vm carries real overhead a bare Worker
doesn't. That's intentional (it reflects what each platform will really
deliver on real jobs), just don't read the raw CCU number as a pure
hardware benchmark across platforms.
Watch it happen: open the dashboard while a new device joins — it briefly shows "benchmarking," then drops into its ranked slot with a tier badge.
The per-device leaderboard above is the detail view. The headline number,
shown big at the top of the dashboard, is totalCcu — every ready device's
aggregateCcu added together into one figure representing the whole
network's current power, plus an overall tier of its own (🧩 Starter
Cluster → 🔗 Small Cluster → 🖥️ Serious Supercomputer → 🏭 Datacenter-Class;
separate, much higher thresholds than the per-device tiers, see
server/benchmark.js).
That number needs no configuration to track your swarm: it's recomputed
from scratch on every state change, so connect a third laptop and it rises
immediately; unplug a phone and it drops immediately. You don't manage
capacity — you just watch it change as devices come and go. It's also what
you'd read from a script instead of the dashboard: GET /api/network
returns the same swarm object.
Rank (phone vs. tablet vs. desktop) decides how fast a device's slots are. Contribution is the second, independent knob: a 0–100% throttle you set per device, deciding how many of its slots the swarm is actually allowed to use. A quad-core laptop at 100% lends all 4 slots; the same laptop at 50% lends 2 (rounded, always at least 1 unless you go all the way to 0%, which pauses that device — it stays connected and ranked, but is handed no work until you bring it back up).
It's just a live multiplier: task-assignment concurrency (effectiveSlots)
and every CCU figure on the dashboard — per-device and the swarm-wide hero
number — are computed after applying it, so "one supercomputer" always
reflects what people have actually agreed to lend, not each device's full
rated capacity. The dashboard still shows the full-power (rated) figures
alongside so you can see the headroom you're leaving on the table.
You can set it in three places, and they all change the exact same server-side number:
- At join, from the CLI:
node worker-cli/agent.js --contribution=50 ...(default 100). - At join, from a phone/tablet's
/joinpage: a slider right on the join screen, before you even tap "Join the swarm". - Live, anytime, from either place: drag the slider again after joining (CLI agents don't have a UI, so use the dashboard for those), or the dashboard's own per-device slider, which can throttle any device on the swarm remotely — handy for "my laptop's fan is spinning up, dial it back" without touching that laptop at all.
Dialing a busy device down doesn't kill its in-flight tasks — it just stops being handed new ones until its running count drops back under the new, lower limit, so nothing gets cut off mid-task.
Say you've got a desktop, 2 phones, a tablet, and a laptop — devices 1–5.
virtual-node/ turns the swarm they form into device
6: a real, login-able Linux container whose reported spec (usable
cores, RAM, power tier) is read live off however many of those 5 are
currently connected and contributing — add a 6th donor and it gets stronger
immediately, unplug one and it gets weaker immediately, all through the same
/api/virtual-node numbers the dashboard already computes. It's a real OS
you log into, not a simulation — but, same physical limit as everywhere
else in this README, only work you run through its cloudlet-run job
command is actually distributed; an arbitrary unmodified program at its
shell just uses that one container's own resources.
The dashboard's Node 6 card has two one-click builds: a 🐳 terminal
(needs Docker on the coordinator's own machine; connect afterward with
docker exec) and a 🖥️ desktop — set a password and get a real XFCE
desktop that opens in a browser tab via noVNC, no VNC client needed. For
SSH access from a different machine on the network instead, or a
from-scratch walkthrough of either, see
virtual-node/README.md.
npm install
npm start # starts the coordinator on :7777Open the dashboard at http://localhost:7777 — it updates live as devices join and jobs run.
Add a laptop/desktop to the swarm:
node worker-cli/agent.js --host=ws://<coordinator-ip>:7777 --name="My Laptop"Add a phone/tablet to the swarm: on that device's browser, go to
http://<coordinator-ip>:7777/join, optionally rename yourself, and tap
Join the swarm. Keep the tab open — closing it removes that device from
the pool (its in-flight task is automatically requeued to another device).
Find <coordinator-ip> with ifconfig / ipconfig (the coordinator's LAN
IP) — any device on the same Wi-Fi/network can then reach it.
Run a demo job against whatever's currently connected:
npm run demo:mandelbrot # renders demo/mandelbrot.png, split across the swarm
npm run demo:primes # counts primes below 5,000,000, split across the swarm
npm run demo:container # distributed pi estimation via a real Python container (see below)Watch the dashboard while a demo runs — you'll see each device light up as "busy" and its completed-task count climb.
Besides plain JS functions, a job can be a real Docker container, built
from a Dockerfile you provide, run once per task via docker run on
whichever connected devices actually have Docker. This is for workloads a
sandboxed JS function can't do: your own language/runtime, system packages,
compiled binaries.
Only CLI-agent devices with a working Docker daemon are eligible — a
browser tab has no container runtime at all (nothing to run docker with),
so phones and browser workers are automatically skipped for container jobs
and keep contributing to ordinary function jobs instead. The CLI agent
detects Docker on startup and logs it:
[cloudlet-agent] reporting: 8 slot(s), 16 GB RAM, GPU: none detected, Docker: available (container jobs enabled)
If it says "Docker: not available," install/start Docker Desktop (or Docker Engine) on that machine — no Cloudlet-side configuration needed, it's detected automatically on the agent's next start.
Submit a container job with kind: 'container' instead of a mapFnSource:
await fetch('http://localhost:7777/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'my container job',
kind: 'container',
dockerfile: `FROM python:3.12-slim\nCOPY payload.py /payload.py\nENTRYPOINT ["python3", "/payload.py"]\n`,
buildContext: {
'payload.py': { content: 'import json,os\nprint(json.dumps({"ok": True}))', encoding: 'utf8' },
},
memoryLimit: '256m', // optional, passed as `docker run --memory`
cpuLimit: '1', // optional, passed as `docker run --cpus`
networkAccess: false, // optional, default false -> `--network=none`
payloads: [{ x: 1 }, { x: 2 }],
}),
}).then(r => r.json());The image is built once per device the first time that device gets a task
for the job (subsequent tasks for the same job reuse it — this is cached
in-memory, so a rebuild only happens if the agent restarts), then
docker run once per task: your container reads the task's payload from
the CLOUDLET_PAYLOAD environment variable (JSON) or stdin (same JSON),
and whatever it prints to stdout becomes that task's result — parsed as
JSON if it's valid JSON, otherwise handed back as a raw string.
A few things worth knowing:
buildContextis text/JSON-friendly by default. A file withencoding: 'base64'can carry binary content (a compiled binary, an image); either way, if that file needs to be executable, setmode: '755'on it explicitly — permissions don't survive the trip through JSON otherwise, and you'll get a confusing "permission denied" from a file that has the right content but the wrong mode.- Every
docker rungets--rm --pids-limit=256and, by default,--network=none— setnetworkAccess: trueonly if the container genuinely needs to reach the internet or your LAN at run time (build-time network, e.g.pip installinside the Dockerfile, is unaffected either way — that happens once duringdocker build, before any of this). memoryLimit/cpuLimitare the only knobs job submitters get — there's deliberately no way to pass arbitrary extradocker runflags from a job submission (no--privileged, no volume mounts), so a malicious or buggy job can't ask a device to mount its filesystem or escalate privileges. If you need more than that, editworker-cli/docker.jsdirectly on devices you control.- Same fault tolerance as function jobs — if a device disconnects mid-container, its in-flight tasks are requeued to another Docker-capable device exactly like a dropped function task.
A job is just a plain function (as source, sent to every device) plus an array of JSON-serializable payloads — one task per array item:
function myMapFn(payload) {
// do some work with payload, return a JSON-serializable result
return payload.x * payload.x;
}
await fetch('http://localhost:7777/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'square some numbers',
mapFnSource: myMapFn.toString(),
payloads: [{ x: 1 }, { x: 2 }, { x: 3 }],
}),
}).then(r => r.json()); // -> { jobId }Then poll GET /api/jobs/:jobId until status === "completed"; the
results array lines up with your original payloads array, in order.
Rules for mapFnSource: it must be fully self-contained — no closures
over anything outside itself, since it's evaluated fresh on a remote device.
It can use plain JS built-ins (Math, JSON, etc.) but not require,
fetch, or the DOM.
If a device disconnects mid-task (phone locks, laptop closes lid, agent is killed), the coordinator notices via a 15s heartbeat, automatically requeues that device's in-flight task, and hands it to another device. A job only ever reports "completed" once every task has a result. This was verified by killing a worker mid-job during testing — the job still finished with the correct result.
This prototype is built for devices and networks you trust (your own phone + laptop, a team's shared LAN), not for running untrusted code from strangers on the public internet:
- Tasks execute as arbitrary JavaScript. The browser worker runs it inside a
dedicated Web Worker (its own JS realm, no DOM access); the CLI agent runs
it inside a Node
worker_threadsthread using thevmmodule (its own global object, norequire). Neither is a hardened, escape-proof sandbox — do not point Cloudlet at devices controlled by people you don't trust, and don't feed it job code from a source you don't trust either. - There's no authentication between coordinator and workers yet, and no TLS — anyone who can reach the coordinator's port on your network can submit jobs or connect as a worker. Fine for a home/office LAN behind a router; add a shared token check and put it behind HTTPS/WSS before exposing it beyond that.
- There's no verification that a worker's returned result is honest (real volunteer-computing systems like BOINC handle this with redundant computation + majority voting across multiple devices). Not implemented here — a natural next step if you want to run this on less-trusted hardware.
- A device's reported
coressets how many task slots the coordinator will hand it — a device could claim far more cores than it has and simply sit on those tasks. On disconnect they're requeued (see Fault tolerance), so the worst case is a stalled task waiting out one 15s heartbeat window, not a lost result — but it's a nuisance vector worth knowing about before trusting Cloudlet to devices you don't control. - Container jobs are meaningfully more powerful than function jobs, and
that cuts both ways. A Dockerfile can install whatever it wants at build
time and, unless you keep
networkAccess: false(the default), reach the network at run time — this is real, unsandboxed code with a real OS underneath it, not avmcontext. Cloudlet limits what a job submission can do to a device (no arbitrarydocker runflags, no volume mounts, no--privileged, resource limits applied server-side), but it does not limit what the container image itself does once it's running. Only run container jobs whose Dockerfile you wrote or reviewed, on devices you control — this is not a system for running strangers' containers.
cloudlet/
├── server/
│ ├── coordinator.js HTTP API + WebSocket server + scheduling + ranking glue
│ ├── jobManager.js job/task state machine (pure logic, no networking)
│ └── benchmark.js the standard benchmark task + CCU scoring + tiers
├── worker-browser/
│ ├── index.html the "join the swarm" page (open on phone/laptop)
│ └── compute-worker.js Web Worker that actually executes tasks
├── worker-cli/
│ ├── agent.js CLI agent: one device, one thread-pool slot per core
│ ├── workerPool.js small persistent worker_threads pool
│ ├── taskRunner.js worker_thread entry point that executes function tasks
│ ├── docker.js builds/runs container tasks via the local Docker daemon
│ └── hostinfo.js best-effort memory/GPU detection (no extra deps)
├── dashboard/
│ └── index.html live leaderboard + job progress, served at "/"
├── shared/
│ └── protocol.js message-type constants shared by server + clients
├── virtual-node/ "Node 6" - a login-able container built from the swarm
│ ├── Dockerfile
│ ├── entrypoint.sh
│ ├── cloudlet-run.js the job-submission CLI that runs inside Node 6
│ ├── cloudlet-status.sh live spec banner shown at login
│ └── examples/ a sample --map function + payloads
└── demo/
├── submit-mandelbrot.js distributed fractal render -> mandelbrot.png
├── submit-primes.js distributed prime counting
└── submit-container.js distributed pi estimation via a real Python container