How RingScript got a library ecosystem

And what happened when a second application used it.

Deep technical August 2026 · from the RingScript project

Update, 22 August: the set this post opened is now complete — three libraries, every one consumed by a shipped sample, and the outbox rebuilt partition-tolerant after a real field outage. The follow-up is The local-first set is complete. Version numbers and transcripts below are from the day the registry opened, and are kept as written.

Two of our samples had written the same thing. A field-sales order pad and a stock-count pad both needed a queue of finished work waiting for a connection, and both had written one from scratch — the same ids, the same rollback, the same mistakes available to make. That is the moment a project needs libraries, and RingScript did not have any.

It has them now: a registry, a package manager, and one published library. The plumbing took a day. The part worth writing down is what happened after.

The wrong answer first

Ring already has a package manager. RingPM installs, updates, removes, and keeps a registry that is one Ring file in a repository. RingScript is already listed in it. The obvious move was to make a RingScript library a RingPM package and be done in an afternoon.

That is what the first draft of the design said, and it was wrong. The starter kit says why, in its own words:

Everything needed to run it is already in this folder. Nothing to install.

A RingScript user is a web developer. They downloaded a folder, they double-click a launcher, a page runs. They have a browser and that folder. They do not have Ring installed, and they have no reason to — the virtual machine they run is the WebAssembly one, not the desktop interpreter. Routing libraries through RingPM would mean telling them to install a language runtime they will never invoke, in order to add a service worker to a page.

There is a quieter reason too. Ring and RingScript share a language and nothing else: different VMs, different platforms, different release cadences, different audiences. A browser library in Ring's registry would be installable by Ring users for whom it does nothing at all. Two ecosystems that share a syntax are still two ecosystems.

What we built on instead

RingScript already ships a native binary, cross-compiled to five platforms, and the starter kit already copies it into the user's folder. It needs no runtime and no interpreter. It was already in their hands — so it grew subcommands rather than a new dependency appearing:

ringscript add pwa
ringscript update          # or `update pwa` for just the one
ringscript remove pwa
ringscript list
ringscript search outbox
ringscript pack            # validate a library you are writing

Written in Zig, so gzip, tar, sha256 and TLS all come from the standard library and the whole ecosystem inherits the property that made the starter kit work: nothing to install.

The registry is one JSON file in its own repository. No server, no accounts, no publish token — a pull request is the review. That is RingPM's own good idea, applied to a registry that is ours.

The one thing every package manager leaves undone

Installing is not copying files. Every package manager stops at "the files are on disk", and the work that remains — a script tag, a stylesheet link, an importScripts line, a cache list — is left to the reader and is exactly where a beginner stalls.

So add finishes the job:

$ ringscript add pwa
  fetching pwa v1.1.0
  verified 12684 bytes against the registry hash
  wired into index.html
  added pwa v1.1.0 to .

It records what it touched, so remove undoes exactly that and no more. After an add and a remove, index.html is byte-identical to before.

And it refuses rather than warns. A download that does not match the registry hash is refused with both hashes printed and nothing written to disk; a version needing a newer runtime is refused before it can fail in somebody's browser. Both were tested by feeding the installer a deliberately broken registry, not by asserting they work.

A detail that will save somebody a bad week

The design said to pin the hash of codeload.github.com/…/tar.gz/refs/tags/…. We did not, because GitHub's auto-generated source archives are produced on demand and are not guaranteed byte-stable — they have changed before. Pinning a sha256 to one pins it to something that can move underneath you, which defeats the point of pinning.

Every release therefore attaches a tarball built with git archive and uploaded as an asset, which is immutable, and the registry carries that URL. Before each row went in, the published asset was downloaded back and re-hashed.

Then the second user arrived

The first library is ringscript-pwa: installability, the offline shell, and the outbox both samples had been hand-rolling. Its Ring half arrived with fourteen tests, all green, run in Node with no browser at all.

Then we installed it into the stock-count pad, and drove the result like a user.

It sent the same order twice. Restoring the connection calls flush(). Clicking Send now calls flush(). Doing both within 100 ms had each read the same entry as queued, and each send it:

one queued entry, two overlapping flushessends
before2
after1

That is precisely the duplicate this library exists to prevent — and the gap is instructive. The id is made on the device before anything is sent, so a retry can never become a second order. That guard does nothing about two senders racing. Both are needed; only one was there.

Note where the bug was not: the Ring half, whose fourteen checks were green throughout and stayed green. It lived in the wiring, and only a real page driven like a user found it.

And the third user changed the design

Then we rewired the order pad, and it could not use the library at all.

A stock count is one document a shift, so sending one entry at a time suited it. A route is a day's worth of orders, and there one-at-a-time is not safer — it is worse: ten requests are ten chances to fail on a bad link. What that application needs is one request, with the server answering per order, so that one refused order does not lose the other nine.

So the library grew a batch. Not because a batch is tidier, but because the second consumer proved the first design was a special case wearing the clothes of a general one.

pwa.flushBatch(function (batch) {
	return fetch("/api/orders", { method: "POST", body: JSON.stringify(batch) })
		.then(function (r) { return r.json(); });
});

If the request never arrives, the whole batch rolls back to queued. If it arrives, each entry takes its own verdict and keeps the reason it was refused.

What it cost the samples

samplelines removedwhat stayed in Ring
stock-count191the money rules, and whether a count may be submitted
route-orders192tier pricing, the credit limit — and what a refusal costs

That last one is the line worth defending. When the server rejects an order, the customer's credit goes back, because the order did not happen. A queue cannot know that. It is a sales rule, so it stayed in Ring while everything around it moved into the library.

What we actually learned

A registry is not an ecosystem. A registry is a file. What makes a library real is the second application, because the first one cannot tell you what you got wrong — it agrees with you by construction.

Both of this week's real findings came from that second use, and neither would have come from more tests on the first:

Which is the argument for a library in one sentence: the next bug found in either application now fixes both. That stopped being a claim today and became something that happened twice before lunch.

Try it

Two applications, one library, both running in your browser on the real Ring VM.

Stock Count Route Orders

The format, the manifest and the tooling are written up in LIBRARIES.md. The registry is ringscript-registry — one file, and a pull request is the review. If you write something worth sharing, that is the whole of the process.