Creeba is a local-first P2P layer for TypeScript apps. Local databases stay local — the network only carries opaque app payloads, never the database. The message shape and its persistence belong to the application.
It ships as small, composable packages under the @streamline-pulse scope, split
so that native/ORM dependencies never leak into environments that can't use them
(a browser or mobile bundle never pulls the iroh native binding; the core never
pulls Prisma).
| Package | What it is | Native deps |
|---|---|---|
@streamline-pulse/creeba-core |
Portable P2P core: identity, presence, peers, routing of opaque payloads over a pluggable SyncTransport. |
none |
@streamline-pulse/creeba-iroh-mdns |
Bun/Node transport: encrypted QUIC (iroh, holepunch + relay) with LAN discovery over mDNS, serverless. + ed25519 identity helpers. | @number0/iroh |
@streamline-pulse/creeba-expo |
Mobile transport (Expo/React Native): native iroh via iroh-ffi (Swift/Kotlin), same ALPN + wire format as desktop. |
native module |
@streamline-pulse/creeba-oplog |
Portable op-log: hybrid logical clock, operation journal, LWW convergence. Persistence-agnostic. | none |
@streamline-pulse/creeba-oplog-prisma |
Prisma binding for the op-log: transparent mutation capture ($extends), ready-made OpStore + Projection. |
Prisma (peer) |
Your app
├── CreebaSync (creeba-core) — portable, no native dependency
│ └── SyncTransport ← P2P network (pluggable)
│ ├── IrohMdnsTransport (creeba-iroh-mdns) — Bun/desktop/server, LAN
│ └── IrohExpoTransport (creeba-expo) — mobile, same wire format
└── OpLog (creeba-oplog) — optional convergence layer (HLC + LWW)
├── OpStore ← journal persistence (pluggable)
└── Projection ← domain-state writes (pluggable)
└── creeba-oplog-prisma — a ready OpStore + Projection over Prisma
The two layers are independent: use creeba-core alone to move payloads, add
creeba-oplog when you need convergent state across nodes. Persistence is never
part of a core — each app plugs its own store (DuckDB, bun:sqlite,
expo-sqlite, Prisma…).
# transport + core (Bun / desktop / server)
bun add @streamline-pulse/creeba-core @streamline-pulse/creeba-iroh-mdns
# convergence layer (optional)
bun add @streamline-pulse/creeba-oplog
bun add @streamline-pulse/creeba-oplog-prisma # if you use PrismaThe core is generic over the app payload T. The app defines its own message
type and handles persistence itself.
import { CreebaSync } from "@streamline-pulse/creeba-core";
import { IrohMdnsTransport } from "@streamline-pulse/creeba-iroh-mdns";
interface ChatMessage { id: string; userId: string; body: string; ts: number }
const sync = new CreebaSync<ChatMessage>({
transport: new IrohMdnsTransport<ChatMessage>(),
identity: { userId: "abc", metadata: { name: "alice" } },
topic: "creeba-chat",
});
sync.on("data", (message, from) => {/* received from a peer → persist + display */});
sync.on("peer", (peer) => {/* a peer joined → good place to backfill history */});
sync.on("peers", (peers) => {/* full peer list changed */});
sync.on("status", (s) => {/* s.ready, s.publicKey */});
const { publicKey } = await sync.start(); // bind + join the topic
sync.broadcast({ id: "1", userId: "abc", body: "hi", ts: Date.now() });The core carries only live payloads and stores nothing, so replaying the history
a late peer missed is an app concern. CreebaSync gives you the hook — the
peer event (fired once when a peer is identified) — and point-to-point
send(peerId, payload); the app owns the cursor, ordering and dedup.
The recommended shape is a pull (request/response) rather than each peer pushing its whole history. Model it inside your own payload union:
type Wire =
| { t: "msg"; msg: ChatMessage }
| { t: "sync-req"; since: number } // requester's cursor
| { t: "sync-res"; items: ChatMessage[] };
// On join, ask the new peer for what we're missing.
sync.on("peer", (peer) => sync.send(peer.peerId, { t: "sync-req", since: store.latestTs() }));
sync.on("data", (frame, from) => {
if (frame.t === "sync-req" && from) sync.send(from.peerId, { t: "sync-res", items: store.since(frame.since) });
else if (frame.t === "sync-res") for (const m of frame.items) store.insert(m); // idempotent by id
else if (frame.t === "msg") store.insert(frame.msg);
});Dedup by message id (idempotent inserts) makes this robust even if several
peers answer. A full working reference lives in
examples/creeba-chat-elysia.
For convergent state (not just a message log), use the op-log layer below instead of hand-rolling the merge.
creeba-oplog turns mutations into an ordered, mergeable journal (hybrid logical
clock + last-writer-wins), independent of any database. The app implements two
interfaces — OpStore (journal persistence) and Projection (domain writes) —
or plugs in a ready binding. With Prisma, capture is transparent:
import { createOpLog } from "@streamline-pulse/creeba-oplog-prisma";
// $extends the client: every write to a syncable entity is journaled,
// scalar-only (relations dropped), secrets omitted.
const { client, oplog, projection } = createOpLog(prisma, {
nodeId,
syncable: [
{ model: "projects", entity: "Projects", orgField: "groupId" },
// …
],
});
// Feed remote ops in, broadcast local ops out — over creeba-core:
oplog.onLocalOp((op) => sync.broadcast(op));
sync.on("data", (op) => oplog.applyRemote(op, projection));On mobile the transport is provided by
@streamline-pulse/creeba-expo, a native module (Swift/Kotlin
via iroh-ffi) that implements the same SyncTransport. It speaks the same ALPN
- wire format as the desktop, so mobile and desktop peers interoperate on the LAN.
Requires a dev build — the native module does not run in Expo Go.
npx expo install @streamline-pulse/creeba-core @streamline-pulse/creeba-expoAdd the plugin in app.json / app.config.js. It wires up the iOS local-network
permission + Bonjour service and the Android network/multicast permissions:
{
"expo": {
"plugins": [
["@streamline-pulse/creeba-expo", { "localNetworkUsageDescription": "MyApp uses the local network to discover nearby peers." }]
]
}
}localNetworkUsageDescription is optional (a sensible default is used).
-
iOS: minimum deployment target 17.5 (set by the plugin), and the
cocoapods-spmgem must be installed so the iroh-ffi Swift Package resolves:gem install cocoapods-spm
-
Android: no extra step — the plugin adds the permissions and the JNA dependency automatically.
Then generate the native projects and run a dev build:
npx expo prebuild
npx expo run:ios # or: npx expo run:androidUse IrohExpoTransport exactly like IrohMdnsTransport on desktop. The app owns
its message type and its local persistence (e.g. expo-sqlite):
import { CreebaSync } from "@streamline-pulse/creeba-core";
import { IrohExpoTransport } from "@streamline-pulse/creeba-expo";
const sync = new CreebaSync<ChatMessage>({
transport: new IrohExpoTransport<ChatMessage>(),
identity: { userId: "abc", metadata: { name: "alice" } },
topic: "creeba-chat",
});
await sync.start();Tip: guard the native import so the app still runs in Expo Go / web (falling back
to a no-op transport), as shown in
examples/creeba-chat-expo/src/sync.
Monorepo / linked packages:
creeba-expoships its ownnode_moduleswith possibly mismatched copies ofreact-native/expo-modules-core. Configure Metro to resolve a single instance of these from the app (seeexamples/creeba-chat-expo/metro.config.js), otherwise you'll hitPlatformConstants could not be foundat runtime.
Discovery in creeba-iroh-mdns is mDNS = local network only — it does not
cross routers. The iroh connection itself already traverses the internet
(QUIC holepunch + public relays) once you have a peer's ticket; what mDNS
provides is finding that ticket on the LAN.
Internet (WAN) rendezvous is on the roadmap as a separate discovery package
(e.g. creeba-iroh-dns / bootstrap-ticket) layered over a shared creeba-iroh
transport core, so creeba-iroh-mdns stays the LAN variant and the public API is
unchanged.
Implement SyncTransport: start() (returns the local id), join(topic),
setIdentity(), send(peerId, frame), broadcast(frame), destroy(), and an
on(event, cb) emitting peer-open / peer-close / frame / error. The
transport ignores frame semantics (hello/data): it carries frames, the core
does the rest.
This is a Bun-workspace monorepo (packages/* + examples/*) with a shared
dependency catalog and coordinated versioning via
changesets. Internal deps use the
workspace:* protocol (rewritten to the concrete version at publish).
Each package builds to dist/ (ESM .js + .d.ts + source maps) via tsc
(rewriteRelativeImportExtensions keeps .ts specifiers in source and emits
.js; scripts/fix-dts.mjs aligns the declaration files). publishConfig.access
is public on every package.
bun install # one install links the whole workspace
bun run build # builds all publishable packages (topological order)
# record a version bump, then let CI open a release PR + publish on merge:
bun run changesetPublishing is automated by .github/workflows/release.yml (changesets action):
on merge to main it runs bun run version-packages then bun run release
(scripts/release.mjs publishes only what isn't already on the registry, deps
first). A manual publish is bun run release after bun run build.
# 2-node P2P test (mDNS discovery + iroh exchange), from the electrobun example:
cd examples/creeba-chat-electrobun
CREEBA_DEBUG=1 bun scripts/p2p-smoke.tsMIT © Streamline Pulse