Skip to content

Repository files navigation

Cloudlet

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.

How it works

                        ┌─────────────────────┐
    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 Node worker_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.

Ranking & benchmarking

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:

  1. 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.
  2. 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.
  3. Aggregate score & tier. aggregateCcu = ccu-per-core × slots estimates 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 in server/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.

It's one supercomputer, not a list of devices

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.

Contribution: how much of each device to lend

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 /join page: 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.

Node 6: a real machine built from the other 5

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.

Quick start

npm install
npm start                       # starts the coordinator on :7777

Open 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.

Running containers on the cluster

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:

  • buildContext is text/JSON-friendly by default. A file with encoding: 'base64' can carry binary content (a compiled binary, an image); either way, if that file needs to be executable, set mode: '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 run gets --rm --pids-limit=256 and, by default, --network=none — set networkAccess: true only if the container genuinely needs to reach the internet or your LAN at run time (build-time network, e.g. pip install inside the Dockerfile, is unaffected either way — that happens once during docker build, before any of this).
  • memoryLimit/cpuLimit are the only knobs job submitters get — there's deliberately no way to pass arbitrary extra docker run flags 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, edit worker-cli/docker.js directly 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.

Writing your own job

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.

Fault tolerance

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.

Security model — please read

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_threads thread using the vm module (its own global object, no require). 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 cores sets 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 a vm context. Cloudlet limits what a job submission can do to a device (no arbitrary docker run flags, 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.

Project layout

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

About

Turn every device on your network into one distributed supercomputer. Phones, tablets, laptops, and desktops pool their spare CPU into a live-ranked swarm, coordinated over WebSockets, that runs JS or Docker jobs in parallel — with a "Node 6" virtual machine (terminal or full XFCE desktop) built dynamically from whatever's currently connected.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages