An App With No Server In the Middle

A hands-on walkthrough you can type yourself: run a real capability call, have two programs find each other across the internet with no server in the middle, and replicate a signed feed — peer to peer.

Two cans, one string, no exchange in the middle.

Almost every app you use has a server sitting in the middle. You post something; it goes to the company's server; your friend's app asks that server for it. The server is the middle — and being the middle means it can be down, be subpoenaed, change the rules, read your messages, or one day just switch off.

For a long time we assumed that middle was unavoidable. This post is a hands-on tour of the two ideas that let you remove it — and, crucially, you can type every step yourself in a couple of terminals and watch it work. No blockchain, no cloud account, no framework. Just two small programs talking directly.

By the end you'll have:

  1. run a real capability call where one program invokes an object that lives in another program, and
  2. had two programs find each other across the internet with no server, pass a capability between them, and replicate a signed feed — peer to peer.

If you can copy-paste into a terminal, you can do this.

The two ideas

Everything below rests on two moves. Neither is complicated once you see it.

Idea one: authority travels in the thing, not in a database. Normally, when you do something, a server checks a table — "is this user allowed?" That check is why the server has to be in the middle. A capability flips it: the permission is a signed token you hold, and holding it is the permission. Anyone can verify it on the spot, because the proof is inside the token — no table to consult. (If you've ever used a hotel key-card that just works on your door without the lock phoning the front desk, you've held a capability.)

Idea two: programs can find and talk to each other directly. A shared, public "finding service" — a distributed hash table, or DHT — lets any two programs that know a common secret word discover each other's real network address and open a direct connection, punching through home routers automatically. No rendezvous server you have to trust; the DHT is just a public phone book that no one owns.

Put them together and you get apps where the middle is optional. There can still be an always-on helper for convenience — but it's just another peer, not the gatekeeper.

We build these on a small runtime called Bare (from the Holepunch project), which is a lean JavaScript runtime designed for exactly this peer-to-peer world. The capability machinery is Endo (from Agoric). You don't need to know either deeply — the point of this post is to run them.

Setup (five minutes)

You need Node.js installed (any recent version). Everything else we install per-project. Make a folder and drop in the dependencies:

mkdir p2p-playground && cd p2p-playground
npm init -y
npm install bare ses @endo/init @endo/captp @endo/far @endo/eventual-send hyperswarm hypercore b4a

We'll run scripts with Bare, not Node, using npx bare yourfile.mjs. Bare is where the peer-to-peer pieces feel at home. (The very first run of npx bare downloads the runtime; that's normal.)

One tiny thing to know: Bare is not Node, so a few Node globals aren't there. There's no process; command-line arguments live in Bare.argv. We'll only need that once.

Step 1 — A safe place to run code you don't fully trust

Before two strangers' programs talk, each one wants to protect itself. If you're going to run code — even your own dependencies — you want the language's built-in objects frozen so nothing can quietly tamper with them. Endo's SES ("hardened JavaScript") does exactly that with one call: lockdown().

Create 1-lockdown.mjs:

import 'ses';

lockdown();
console.log('lockdown() ran');

// After lockdown, the shared built-ins are frozen. Prove it: a strict-mode
// write to a global prototype must fail instead of silently poisoning it.
(function () {
  'use strict';
  try {
    Object.prototype.hacked = 'oops';
    console.log('NOT frozen — that is bad');
  } catch (e) {
    console.log('frozen: tampering threw', e.constructor.name);
  }
})();

console.log('Object.prototype frozen?', Object.isFrozen(Object.prototype));

Run it:

npx bare 1-lockdown.mjs

You'll see the tampering throw, and Object.prototype frozen? true. That's the foundation: a hardened sandbox you can run untrusted code inside without it clawing its way out. (Why the strict-mode wrapper? Outside strict mode, an illegal write to a frozen object just silently does nothing — it wouldn't throw, and you might think you'd broken the sandbox when you hadn't. Strict mode turns the silent no-op into a visible error, so the test actually proves something.)

Step 2 — Calling an object that lives somewhere else

Here's the capability idea made concrete. We'll create a little Counter object, then hand a reference to it across a boundary and call its methods from the other side — as if it were local. The machinery that makes this possible is CapTP (capability transport protocol). It lets you hold a live reference to an object that lives elsewhere and call it with E(thing).method() — the E() means "this call might travel, so give me a promise for the result."

To keep this step to one file, we wire the two sides together in memory (a pretend network). In Step 3 we'll replace that pretend wire with a real one between two computers.

Create 2-captp.mjs:

import '@endo/init';           // sets up hardened JS + the plumbing CapTP needs
import { makeCapTP } from '@endo/captp';
import { Far } from '@endo/far';
import { E } from '@endo/eventual-send';

// A real capability: an object with behaviour. `Far` marks it as safe to
// expose across a boundary.
const counter = (() => {
  let n = 0;
  return Far('Counter', { bump: () => (n += 1), read: () => n });
})();

// Two CapTP "ends" wired straight to each other (our in-memory pretend wire).
let sendToClient, sendToServer;
const server = makeCapTP('server', (msg) => sendToClient(msg), counter);
const client = makeCapTP('client', (msg) => sendToServer(msg));
sendToClient = (msg) => client.dispatch(msg);
sendToServer = (msg) => server.dispatch(msg);

// The client only has a reference to the server's counter, not the object.
const remote = client.getBootstrap();
console.log('bump ->', await E(remote).bump()); // 1
console.log('bump ->', await E(remote).bump()); // 2
console.log('read ->', await E(remote).read()); // 2

Run it:

npx bare 2-captp.mjs

You just called bump() and read() on an object the client side never held — it only held a capability to it. That's the whole trick: authority (the ability to bump the counter) travelled as a reference, and the call went "over the wire" (here, our fake in-memory wire) and came back with a real answer.

Step 3 — Two programs, no server (the punchline)

Now the fun part. We'll take the same counter-over-CapTP idea and run the two ends as two separate programs that find each other over the real peer-to-peer network and connect directly. No server in the middle.

The finder is Hyperswarm: both programs "join" the same secret topic (a 32-byte value — we just hash a phrase), and the DHT introduces them. Whatever bytes one side writes, the other receives — a real duplex connection. We run our CapTP messages over it, one JSON message per line.

Create 3-provider.mjs (this side owns the counter):

import '@endo/init';
import { makeCapTP } from '@endo/captp';
import { Far } from '@endo/far';
import Hyperswarm from 'hyperswarm';
import crypto from 'bare-crypto';

const counter = (() => {
  let n = 0;
  return Far('Counter', { bump: () => (n += 1), read: () => n });
})();

const topic = crypto.createHash('sha256').update('my-p2p-playground').digest();
const swarm = new Hyperswarm();

swarm.on('connection', (conn) => {
  console.log('provider: a peer connected — serving the counter');
  const { dispatch } = makeCapTP('provider', (m) => conn.write(JSON.stringify(m) + '\n'), counter);
  let buf = '';
  conn.on('data', (d) => {
    buf += d.toString();
    let i;
    while ((i = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, i); buf = buf.slice(i + 1);
      if (line) dispatch(JSON.parse(line));
    }
  });
});

await swarm.join(topic, { server: true, client: true }).flushed();
console.log('provider: waiting for a peer...');

Create 3-consumer.mjs (this side calls the counter it doesn't own):

import '@endo/init';
import { makeCapTP } from '@endo/captp';
import { E } from '@endo/eventual-send';
import Hyperswarm from 'hyperswarm';
import crypto from 'bare-crypto';

const topic = crypto.createHash('sha256').update('my-p2p-playground').digest();
const swarm = new Hyperswarm();
let done = false;

swarm.on('connection', async (conn) => {
  if (done) return;
  console.log('consumer: connected to a peer — calling the counter');
  const { dispatch, getBootstrap } = makeCapTP('consumer', (m) => conn.write(JSON.stringify(m) + '\n'));
  let buf = '';
  conn.on('data', (d) => {
    buf += d.toString();
    let i;
    while ((i = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, i); buf = buf.slice(i + 1);
      if (line) dispatch(JSON.parse(line));
    }
  });

  const remote = getBootstrap();
  await E(remote).bump();
  await E(remote).bump();
  console.log('read across the network ->', await E(remote).read()); // 2
  done = true;
});

swarm.join(topic, { server: true, client: true });
console.log('consumer: looking for the provider...');

Now open two terminals in the same folder. In the first:

npx bare 3-provider.mjs

Wait until it says it's waiting, then in the second:

npx bare 3-consumer.mjs

Give it a few seconds — the DHT introduction can take a moment on a cold start. Then the consumer prints read across the network -> 2. Stop and appreciate what just happened: two separate programs found each other over the public internet, opened a direct connection, and one invoked a capability living inside the other — and nothing in the middle brokered it. (Want to feel it more? Run the two terminals on two different computers on different networks. As long as both use the same topic phrase, they'll find each other.)

Step 4 — Owning your data: a feed that replicates itself

Calling methods is the "verbs." The "nouns" — your posts, your data — want to be yours too, and to reach other people directly. That's Hypercore: an append-only log that you own (it's tied to a keypair you hold), and that any peer can replicate straight from you. Every entry is signed and hash-chained, so a peer replicating your log can verify each entry is really yours and unaltered — tampering is detected, not trusted-away.

Create 4-producer.mjs:

import Hypercore from 'hypercore';
import Hyperswarm from 'hyperswarm';
import b4a from 'b4a';

const core = new Hypercore('./my-feed'); // a folder to store the log
await core.ready();
await core.append(b4a.from('post 1: hello'));
await core.append(b4a.from('post 2: this is my own feed'));
await core.append(b4a.from('post 3: replicated peer to peer'));

// Print the feed's public key — the reader needs it to find + verify the log.
console.log('FEED KEY:', b4a.toString(core.key, 'hex'));

const swarm = new Hyperswarm();
swarm.on('connection', (conn) => core.replicate(conn));
swarm.join(core.discoveryKey, { server: true, client: true });
console.log('producer: serving', core.length, 'entries...');

Create 4-reader.mjs — it takes the feed key as an argument:

import Hypercore from 'hypercore';
import Hyperswarm from 'hyperswarm';
import b4a from 'b4a';

const keyHex = Bare.argv[Bare.argv.length - 1]; // the key you pass on the command line
const core = new Hypercore('./their-feed', b4a.from(keyHex, 'hex'));
await core.ready();

const swarm = new Hyperswarm();
swarm.on('connection', (conn) => core.replicate(conn));
swarm.join(core.discoveryKey, { server: true, client: true });

// Wait until the producer tells us how long the feed is, then read it all.
while (core.length === 0) { await core.update({ wait: true }); await new Promise((r) => setTimeout(r, 300)); }
for (let i = 0; i < core.length; i++) console.log('got:', b4a.toString(await core.get(i)));

First terminal:

npx bare 4-producer.mjs

It prints a line like FEED KEY: bd87da04.... Copy that hex string. Second terminal:

npx bare 4-reader.mjs bd87da04...   # paste the key you copied

The reader replicates the log directly from the producer and prints all three posts. There was no server holding your feed — the producer is the source, and the reader pulled it peer to peer, verifying every entry against the log's signature as it read.

(Two gotchas if you're tinkering: the log is stored in a folder — ./my-feed — so if you re-run the producer, stop the old one first, because it holds a lock on that folder. And run the reader from the same project folder so it can find its dependencies.)

What you just built

Four small scripts, and you've touched the whole spine of a serverless app:

  • A hardened sandbox to run code safely (lockdown()).
  • Capabilities — invoking an object by holding a reference to it, not by asking a server for permission (CapTP + E()).
  • Peer discovery + a direct connection with no rendezvous server (Hyperswarm).
  • Owned, self-verifying data that replicates peer to peer (Hypercore).

The magic isn't any one piece — it's that they compose. A capability call can ride the peer-to-peer connection (Step 3). A feed can be a capability someone hands you. The "server" becomes just a peer that happens to always be online: useful for availability and discovery, but never the gatekeeper, never the thing you have to trust.

Where this goes next

Two honest edges, so you know the map:

  • Many writers, one shared timeline. Everything above is single-writer: one keypair owns each feed. A group chat or a comment thread has many authors. Holepunch's Autobase merges several writers' logs into one deterministically-ordered shared view — that's the next primitive to explore, and the next thing we're spiking.
  • Wrapping it in a real app. These scripts run in a terminal. To make a desktop app, you embed this same runtime inside a native shell (we use Tauri) so the peer-to-peer engine runs behind an ordinary window — same code, real product.

But none of that changes the core you just ran with your own hands: two programs, trusting each other directly, with no one in the middle.

This is the lay-reader companion to ADR 0050, "P2P substrate: capabilities on Bare." The engineering decision record and the exact spikes these examples are distilled from live alongside it for anyone who wants the full depth.

End of Transmission.