Skip to content
AlexJeffcottPublic

About

Opinionated multi-execution-context framework for Javascript applications.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

@fairfox/polly

Reactive state for multi-context apps — from single-process signals to peer-to-peer encrypted mesh — with formal verification.

The pitch

Define state once. Read it anywhere. Polly keeps them in sync.

// src/shared/state.ts
import { $sharedState } from "@fairfox/polly/state";

export const counter = $sharedState("counter", 0);
// src/background/index.ts — service worker, extension background, Node process
import { createBackground } from "@fairfox/polly/background";
import { counter } from "../shared/state";

const bus = createBackground();

bus.on("INCREMENT", () => {
  counter.value++;
  return { count: counter.value };
});
// src/popup/index.tsx — browser popup, any Preact/React UI
import { render } from "preact";
import { counter } from "../shared/state";

function App() {
  return (
    <div>
      <p>Count: {counter.value}</p>
      <button onClick={() => counter.value++}>+</button>
    </div>
  );
}

render(<App />, document.getElementById("root")!);

The background writes counter. The popup reads it. Both stay in sync — no message passing, no subscriptions to manage. State is a Preact Signal, so the UI re-renders automatically when the value changes.

State that syncs everywhere

Modern apps run code in multiple isolated contexts: service workers, content scripts, server processes. Keeping state consistent across them means writing sync logic for every piece of data. Polly replaces that with four primitives:

Primitive Syncs Persists Use for
$sharedState yes yes User data, settings, auth — anything that should survive a restart and stay consistent
$syncedState yes no Ephemeral shared state: connection status, live collaboration flags
$persistedState no yes Per-context settings, form drafts
$state no no Local UI state: loading spinners, modal visibility

All four return Preact Signals. Read with .value, write with .value =.

import { $sharedState, $syncedState, $persistedState, $state } from "@fairfox/polly/state";

const user = $sharedState("user", { name: "Guest", loggedIn: false });
const wsConnected = $syncedState("ws", false);
const draft = $persistedState("draft", "");
const loading = $state(false);

For async data, $resource fetches and re-fetches automatically when its dependencies change:

import { $resource } from "@fairfox/polly/resource";

const todos = $resource("todos", {
  source: () => ({ userId: user.value.id }),
  fetcher: async ({ userId }) => {
    const res = await fetch(`/api/todos?userId=${userId}`);
    return res.json();
  },
  initialValue: [],
});

todos.data;   // Signal<Todo[]>
todos.status; // Signal<"idle" | "loading" | "success" | "error">
todos.refetch();

Peer-first state (v0.21.0)

The four primitives above keep state consistent inside a single deployment. But some applications need state that survives the server going away, or state that the server never sees at all. Polly now offers three resilience tiers, each a distinct primitive family:

Tier Primitive Server's role Resilience
Weakest $sharedState Source of truth Server backups
Middle $peerState Full peer on the data path Any device can rehydrate the server
Strongest $meshState Not on the data path App works with zero server uptime

$peerState — every device holds a full Automerge CRDT replica. The server holds one too, so cron and HTTP handlers can read and mutate documents. If the server loses its storage, any reconnecting client repopulates it through the normal sync protocol.

import { createPeerStateClient, configurePeerState, $peerState } from "@fairfox/polly/peer";

const client = createPeerStateClient({ url: "wss://yourapp.com/polly/peer" });
configurePeerState(client.repo);

const settings = $peerState("settings", { theme: "dark" });
await settings.loaded;
settings.value = { theme: "light" }; // syncs to every peer

$meshState — peers exchange operations directly over WebRTC data channels, signed with Ed25519 and encrypted with XSalsa20-Poly1305. No server sees the data. A small stateless signalling server helps peers find each other; removing it does not affect running connections.

import { configureMeshState, $meshState, MeshNetworkAdapter, MeshWebRTCAdapter } from "@fairfox/polly/mesh";

const repo = new Repo({ network: [new MeshNetworkAdapter({ base: webrtcAdapter, keyring })] });
configureMeshState(repo);

const notes = $meshState("notes", { entries: [] });
// Operations flow peer-to-peer, signed and encrypted

First-time key exchange between devices uses a pairing token displayed as a QR code. Compromised devices are revoked via signed revocation records that propagate to every peer.

The three tiers coexist in one application — public settings in $sharedState, collaborative documents in $peerState, private notes in $meshState. See docs/STATE.md for the full decision tree and docs/RFC-041-choosing.md for the design rationale.

Node and Bun are first-class mesh peers

Archival cron, LLM proxies, admin CLIs, headless bridges — every always-on participant gets the same state primitives as the browser, without monkey-patching globals or writing bespoke transport wiring. Polly ships a factory that accepts injectable transport and storage:

import { createMeshClient, $meshState } from "@fairfox/polly/mesh";
import { bootstrapCliKeyring, fileKeyringStorage } from "@fairfox/polly/mesh/node";
import { RTCPeerConnection } from "werift";              // or '@roamhq/wrtc'

const storage = fileKeyringStorage("~/.fairfox/keyring.json");
const keyring = await bootstrapCliKeyring({ storage });   // first run prompts for a pairing token

const client = await createMeshClient({
  signaling: { url: "wss://example.com/polly/signaling", peerId: "cli-a1b2" },
  rtc:       { RTCPeerConnection },
  keyring,
});

const doc = $meshState("agenda", { items: [] });
await doc.loaded;
await client.close();

createMeshClient is runtime-agnostic — in a browser the rtc option is optional because globalThis.RTCPeerConnection exists. The @fairfox/polly/mesh/node subpath adds filesystem-backed keyring storage, atomic writes with 0600 permissions, and the stdin bootstrap flow. Neither werift nor @roamhq/wrtc is bundled; both are declared as optional peer dependencies. Pick the one that fits your deployment — werift installs cleanly anywhere (pure TypeScript, no native deps), @roamhq/wrtc is faster but needs prebuilt binaries for your platform.

Blob storage for large files

CRDT documents shouldn't carry binary payloads — the op history grows with every sync. Polly ships a content-addressed blob store that transfers files peer-to-peer over the same WebRTC channels as $meshState, with no server storage. Documents hold lightweight BlobRef values; the bytes live in a local IndexedDB cache and move between peers in 64 KiB chunks.

import { createBlobStore, createBlobRef } from "@fairfox/polly/mesh";

const blobs = createBlobStore(webrtcAdapter, { encrypt: { key: docKey } });

// Sender
const bytes = new Uint8Array(await file.arrayBuffer());
const ref = await createBlobRef({ bytes, filename: file.name, mimeType: file.type });
await blobs.put(ref, bytes);       // caches locally, announces to peers
doc.value = { ...doc.value, attachment: ref };

// Receiver (on any connected peer)
const received = await blobs.get(ref.hash);  // fetches from peers, verifies hash
const url = await blobs.url(ref.hash);        // object URL for <img src>

SHA-256 content addressing deduplicates across peers and documents. Encryption is optional — when configured, the sender encrypts once (XSalsa20-Poly1305) and chunks the ciphertext; the receiver reassembles, decrypts, and verifies the plaintext hash against the BlobRef. See docs/RFC-042-blob-sync.md for the design.

Verification that plugs in

A popup and a background script both write to the same state. A content script reads it mid-update. Tests miss these bugs because they depend on timing.

Polly generates TLA+ specifications from your existing state and handlers, then model-checks them with TLC. You don't learn a new language. You annotate what you already wrote.

Step 1: Add contracts to handlers

requires() and ensures() are runtime no-ops. polly verify reads them statically.

import { createBackground } from "@fairfox/polly/background";
import { requires, ensures } from "@fairfox/polly/verify";
import { user, todos } from "./state";

const bus = createBackground();

bus.on("TODO_ADD", (payload: { text: string }) => {
  requires(user.value.loggedIn === true, "Must be logged in");
  requires(payload.text !== "", "Text must not be empty");

  todos.value = [...todos.value, {
    id: Date.now().toString(),
    text: payload.text,
    completed: false,
  }];

  ensures(todos.value.length > 0, "Todos must not be empty after add");

  return { success: true };
});

Step 2: Define state bounds

A verification config tells TLC what values to explore:

// specs/verification.config.ts
import { defineVerification } from "@fairfox/polly/verify";

export const verificationConfig = defineVerification({
  state: {
    "user.loggedIn": { type: "boolean" },
    "user.role": { type: "enum", values: ["guest", "user", "admin"] },
    todos: { maxLength: 1 },
  },
  messages: {
    maxInFlight: 2,
    maxTabs: 1,
  },
});

Step 3: Run it

$ polly verify

Generating TLA+ specification...
Running TLC model checker...

  Model checking complete.
  States explored: 1,247
  Distinct states: 312
  No errors found.

All properties verified.

A requires() that does not hold prunes the exploration: the handler is not taken, so the model never reaches the state behind the guard. That is what makes requires() cheap, and it is also why a green run says nothing about whether the guard can be violated — a safety property is true of a system that has stopped.

Add liveness: true (per config, or per subsystem) to check the other half: that nothing sent stays pending for ever. TLC then reports the exact sequence in which a message can never be resolved. It is off by default because it is expensive — measured on an 11-subsystem model, a verify gate went from ~110s to ~512s.

For larger apps, subsystem-scoped verification splits the state space so checking stays fast.

Step 4: Catch the path the model never sees

Every run also reports model coverage — which declared field each handler writes, and the fields nothing modelled touches. The bug it catches is a capability granted off the message bus: a register() method that sets canSend directly instead of through a dispatched, guarded handler. TLC has no transition for it, mutation testing has no modelled line to mutate, and a green run says nothing.

A field no handler writes is flagged as unreachable state. A field a non-dispatched function or method writes — even one a real handler also writes — is flagged as mutated outside any modelled transition. Add --strict to fail closed, so an unmodelled write can't slip through green.

$ polly verify --strict

⚠️  1 declared state field write(s) outside any modelled transition:
   • session.canSend mutated in RecoveryFlow.register() — src/background/index.ts:42

❌ Strict mode: model coverage incomplete

A faster safety pass without Docker

polly verify --engine bun checks the same generated spec with polly's own model checker, in process: invariants and deadlock, no Docker, no JVM. On the example specs it reports TLC's exact state counts at 2.5–9× the speed of TLC with all cores, in a fifth to a half of the memory. It does not check temporal properties (the ensures postconditions): it lists them as not checked, and --strict fails on them. Use it for the edit-and-check loop, and TLC (the default) before you ship. A spec the Bun checker cannot read falls back to TLC. Set verification: { engine: "bun" } to make it the project default.

Executable specs that link to the model

polly bdd adds a third verification stratum alongside verify (TLA+) and mutate (mutation testing): acceptance examples written from the user's perspective, as Gherkin, run across the real factory boundary — the same createBackground your extension boots, never a hand-wired bus that quietly papers over a gap the real path has.

# features/auth.feature
Feature: Signing in
  Scenario: A signed-out user can sign in
    Given the user is signed out
    When the user signs in as "admin"
    Then the user is signed in
    And their role is "admin"

One defineStep binding is dual-use, the same trick that makes requires()/ensures() work: its given/when/then drive the runtime, while its message/stateExpr are read statically. So the example layer, the formal layer and the mutation layer all describe the same handlers and state.

polly bdd          Run the scenarios across the real boundary (red before green)
polly bdd check    Cross-check every scenario against the verification config (Docker-free)
polly bdd new      Scaffold a feature stub seeded from the verify vocabulary

Drop .feature files into your project and polly test discovers a bdd tier automatically.

Reachability witnesses

polly verify --witness closes the loop between the example layer and the model. It turns each scenario's outcome into a per-scenario TLC reachability check: a scenario the exhaustive model proves unreachable is one that lies — green in the runner, impossible in the model — and it fails the run. The honest ones come back reachable, and the counterexample trace is the path a user would take to get there. Outcomes that aren't state-observable (a response-only check) are reported as skipped, never silently passed.

Tag a scenario @forbidden to assert the opposite: a state that must never happen. The witness then passes only when the model proves that state unreachable, and fails — with the trace to the defect — if it can reach it. Desirable outcomes proven possible, undesirable ones proven impossible, from the same Gherkin.

$ polly verify --witness

Reachability witnesses (--witness)
  ✓ reachable   Signing in › A signed-out user can sign in
  ✓ reachable   Signing in › Signing out returns the user to a guest session
  · skipped     Todos › Completing a todo that does not exist is refused

Witness result: ✓ PASS

Quick start

bun add @fairfox/polly preact @preact/signals

The authoring tools load packages a production install does not need, so they are optional peer dependencies. Add the ones for the commands you run as dev dependencies:

Command / entry point Dev dependencies
polly verify typescript-7@npm:typescript@7.1.0-beta
polly verify --witness typescript-7@npm:typescript@7.1.0-beta @cucumber/gherkin@42.0.1 @cucumber/messages@34.2.1
polly visualize typescript-7@npm:typescript@7.1.0-beta
polly mutate, @fairfox/polly/mutate oxc-parser@0.137.0 weapon-regex@1.3.6 (plus typescript-7 for the type filter)
polly bdd, @fairfox/polly/bdd @cucumber/gherkin@42.0.1 @cucumber/messages@34.2.1
# all of them
bun add -d typescript-7@npm:typescript@7.1.0-beta oxc-parser@0.137.0 weapon-regex@1.3.6 \
  @cucumber/gherkin@42.0.1 @cucumber/messages@34.2.1

typescript-7 must keep that alias name: polly imports typescript-7/async. A command whose packages are missing names them and prints the install command.

Scaffold a project:

polly init my-app --type=extension  # or: pwa, websocket, generic

Or start from one of the examples:

git clone https://github.com/AlexJeffcott/polly.git
cd polly/examples/minimal
bun install && bun run dev

Examples

Example What it demonstrates
minimal Counter with $sharedState — the simplest possible Polly app
todo-list CRUD with requires()/ensures(), subsystem-scoped verification
full-featured Production Chrome extension with all framework features
elysia-todo-app Full-stack web app with Elysia + Bun, offline-first
webrtc-p2p-chat Peer-to-peer chat over WebRTC data channels
team-task-manager Role-based collaborative tasks with $constraints() and verified urgent-task counts

CLI

polly init [name] [--type=TYPE]   Scaffold a new project (extension | pwa | websocket | generic)
polly build [--prod]              Build for development or production
polly dev                         Build with watch mode
polly check                       Run all checks (typecheck, lint, test, build)
polly typecheck                   Type-check your code
polly lint [--fix]                Lint (and optionally auto-fix)
polly format                      Format your code
polly test [args]                 Run unit tests (bun test)
polly test --tier=T | --all | --list
                                  Run the multi-tier suite (unit → integration → browser
                                  → e2e → verify) with timed reports
polly test:browser [dir]          Run *.browser.{ts,tsx} in Puppeteer
polly coverage                    Per-file coverage policy and orphaned-test detection
polly verify                      Run formal verification (TLA+/TLC)
  --setup | --validate | --estimate   Generate, check, or size the verification config
  --strict                        Fail on model-coverage gaps
  --engine bun                    Safety pass on polly's own checker, no Docker
  --witness                       Per-scenario reachability checks for your BDD outcomes
polly bdd [run|check|new]         Executable Gherkin across the real boundary, linked to the model
polly mutate [init|run|report|verify|decisions|migrate]
                                  Mutation testing and useless/redundant-test detection
polly visualize                   Generate architecture diagrams (Structurizr DSL)
  --snapshot <path>               Overlay a captured mesh peer-state snapshot
  --export | --serve              Static diagram site (export needs Docker) and a local server
polly gallery [--build|--open]    Serve or export the polly-ui component gallery
polly quality [args]              Run conformance checks (no-as-casting, boundaries, secrets,
                                  server-imports, forbidden-deps, banners, marketing, ...)

Each command takes --help. polly mutate is documented in tools/mutate/README.md.

Quality tooling

Polly ships conformance checks and a browser test harness that consuming applications can adopt directly.

No-as-casting check. Bans TypeScript as type assertions codebase-wide (only as const and the explicit escape hatch as unknown as are allowed). Violations include pattern-specific fix advice. Run as a CLI:

polly quality [--root <dir>] [--exclude-packages <names>] [--exclude-files <names>]

Or import it programmatically for integration into custom check scripts:

import { checkNoAsCasting } from "@fairfox/polly/quality";

const result = await checkNoAsCasting({ rootDir: process.cwd() });
if (result.violations.length > 0) {
  result.print();
  process.exit(1);
}

Browser test harness. A Puppeteer-based harness for testing browser-only code (WebRTC adapters, Preact components, anything needing real DOM). Bundles each test file with Bun.build, serves on an ephemeral port, and collects results:

import { describe, test, expect, done, flush, cleanup } from "@fairfox/polly/test/browser";

describe("my component", () => {
  test("renders", async () => {
    render(<MyComponent />, app);
    await flush();
    expect(app.querySelector("h1")).toHaveTextContent("Hello");
    cleanup(app);
  });
});

done();

Run with polly test:browser tests/browser.

Use with Claude Code

This repo ships with a Claude Code skill that covers the full Polly API — state primitives, peer-first and mesh state, verification, quality tooling, and the browser test harness. Install it in your project so Claude can help you integrate Polly with full context:

# From your project directory
claude install-skill /path/to/polly/.claude/skills

Then ask Claude things like:

  • "How would Polly fit into this project?"
  • "Add verification to my handlers"
  • "Set up $peerState with an Elysia server"
  • "Wire up the no-as-casting conformance check in CI"

If you maintain your own Claude Code skills, consider adding Polly's state-tier decision tree and verification patterns to your project-specific skill so Claude can recommend the right primitive for each piece of state.

Licence

MIT


GitHub · Issues · Examples

About

Opinionated multi-execution-context framework for Javascript applications.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages