npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@risemaxi/graft

v0.6.0

Published

Self-hosted over-the-air web bundle updates for Capacitor apps, applied before the WebView loads.

Downloads

122

Readme

Graft

Self-hosted over-the-air web bundle updates for Capacitor apps. The active bundle is selected natively before the WebView is constructed, so a swap costs exactly one page load and no flash, and the web origin never changes — localStorage, IndexedDB and cookies survive it.

A fork of @capawesome/capacitor-live-update. See NOTICE and UPSTREAM.md.

Status

Pre-release. The lifecycle, the native pointer, the self-hosted protocol and binary deltas are implemented on both platforms; a release is fetched as a patch when one is published and falls back to whole files otherwise, and that path has run end to end on a device.

Contract gating runs end to end: the bundle side derives a requires set into the signed manifest, and both platforms refuse a release naming a plugin the running build does not have.

Install

bun add @risemaxi/graft
bun x cap sync

Then serve the staged bundle from the pre-WebView hook on each platform:

  • Android — set the launcher activity to com.risemaxi.graft.GraftActivity, or call GraftPointer.resolveActiveBundleDirectory(this) from your own BridgeActivity.load().
  • iOS — use GraftViewController as the root view controller, or override instanceDescriptor() and assign GraftPointer.resolveActiveBundleDirectory() to appLocation.

The app must call Graft.ready(). With the default readyTimeout of 10 s, a bundle that never reports is rolled back to the last one that did.

Call it at the end of module initialisation, not from a framework "app ready" hook. The watchdog asks one question — did this bundle's entry chunk parse and evaluate — and reaching the end of the entry module is precisely that answer. A later signal cannot answer it: a bundle broken at module init never reaches the hook either way, and a bundle whose lazily-loaded chunks are broken is already past the hook and fails on interaction, where a rollback would not have helped. Signalling late only widens the window in which a healthy bundle is reverted for being slow.

Configuration

{
  plugins: {
    Graft: {
      serverUrl: 'https://ota.example.com',
      defaultChannel: 'production',
      publicKey: '-----BEGIN PUBLIC KEY-----…-----END PUBLIC KEY-----',
    },
  },
}

serverUrl and publicKey are both required for sync(). There is no unsigned path: a missing key is an error, not a skipped check.

Protocol

Channel document

GET <serverUrl>/v1/channel/<channel>.json, served unsigned and edge-cacheable. It is a hint about which manifest to fetch and nothing more — every value in it is re-checked against the signed manifest before a bundle is installed.

This is the one response a device asks for on every launch, so serve it with a strong ETag. Graft records the tag once the work a document implies has succeeded, and sends it back as If-None-Match; publishing is rare and launches are not, so the steady state is a 304 with no body. A server that sends no ETag is fine — graft stores nothing and the exchange stays a plain 200. The tag is recorded only on success, so an install that failed is retried on the next launch rather than skipped by a tag that outran it.

The tag is stored against the fingerprint it was judged under. A tag says "I have already seen this document"; the conclusion drawn from it says "and there was nothing here for me", and that second half depends on which binary asked. A release published for the next store build sits in the document unselectable, and if the tag survived the update the device would answer 304 and never reconsider — stranded on its embedded bundle with an update waiting. Keying the two together makes a tag recorded under a previous binary simply not match.

{
  "schema": 1,
  "killSwitch": false,
  "releases": [
    {
      "id": "r-1042",
      "counter": 1042,
      "rollout": 25,
      "nativeFingerprint": "9f2c1ab4e77d5306",
      "manifest": "/v1/releases/r-1042/graft-manifest.json",
      "sig": "<base64 RSA PKCS#1 v1.5 over SHA-256 of the manifest bytes>",
    },
  ],
}

A release is eligible when nativeFingerprint equals this binary's, counter > highestInstalledCounter, rollout > bucket, and its id is not blocked. The highest-counter eligible release wins; if none is, the device stays put. killSwitch clears the pointer, so the next launch serves the embedded bundle.

manifest is resolved against the channel document's URL and must land on serverUrl's origin. Release files are fetched as siblings of the manifest — <manifest dir>/<href>.

Serve the manifest as graft-manifest.json. Because files are siblings of it, a release containing a file of the same name resolves to the same URL and one overwrites the other — and manifest.json is a file almost every web app ships. graft-manifest.json is safe because the generator excludes that name from the file set by construction. A manifest whose file list would collide is rejected, so this cannot ship silently, but the error is easier to avoid than to read.

Rollout bucket

bucket = FNV-1a-32(installId) % 100, computed on the device from a random per-install id. It is fixed for the life of the install, so a device keeps its position as a rollout widens. Read it with getInstallId().

Manifest

The signed unit. Its raw bytes are what sig covers, so it is never re-serialised between signing and upload, and never parsed before the signature verifies.

{
  "schema": 1,
  "id": "r-1042",
  "channel": "production",
  "counter": 1042,
  "nativeFingerprint": "9f2c1ab4e77d5306",
  "notBefore": 1786238700,
  "expiresAt": 1793928700,
  "files": [{ "href": "index.html", "sha256": "<hex>", "size": 1234 }],
}

After the signature verifies, the manifest is rejected unless id, counter and nativeFingerprint match the channel entry, channel matches the channel it was fetched from, nativeFingerprint equals this binary's, counter > highestInstalledCounter, and notBefore <= now < expiresAt. href must be a relative path with no . or .. segment.

channel, counter, notBefore and expiresAt are optional to parse and required to verify. The same shape describes a release and the bundle sitting on disk, and the manifest the native build generates for the embedded bundle has no channel or replay window to name — it is never fetched, so it is never verified. A manifest served on a channel that omits any of them is rejected.

Each installed file is verified against its sha256 — whether downloaded or copied from the running bundle — and the manifest is written into the bundle directory only after every file has passed, so an installed bundle is exactly the signed file set. The manifest is written as graft-manifest.json; ship one at public/graft-manifest.json in the native build so the first OTA can reuse the embedded files instead of downloading them.

Patch archive

A release may also be transferred as a patch against a bundle the device already has. One archive per version pair: an 8-byte GRAFTP1\n magic, a length-prefixed plan, then the payloads as a counted sequence of length-prefixed blocks, the whole thing compressed with zstd-19. Lengths are unsigned 32-bit big-endian.

Payloads are referenced by index rather than by name, so the archive contains no paths at all — the only paths anywhere are the manifest hrefs, which are already validated. That also keeps the reader to a few dozen lines on each platform, where a tar reader would be a few hundred with pax and GNU long-name handling to get wrong.

{
  "schema": 1,
  "from": "r-1041",
  "to": "r-1042",
  "ops": [
    { "op": "keep", "href": "assets/app-Ba9.js", "from": "assets/app-Ba9.js" },
    {
      "op": "patch",
      "href": "assets/index-D6T.js",
      "from": "assets/index-4ws.js",
      "payload": 0,
    },
    { "op": "add", "href": "assets/new-Qz1.js", "payload": 1 },
    { "op": "delete", "href": "assets/gone-Xy2.js" },
  ],
}

Each payload is the output of zstd -19 --long=27 --patch-from <base>, computed over uncompressed bytes — diffing two already-compressed files is near-useless, because deflate divergence cascades.

The device fetches GET <serverUrl>/v1/patches/<from>__<to>.gpz. Addressing a patch by path rather than by query is deliberate: it is what lets every request graft makes be answered by a static bucket with no compute in front of it. A server that wants to synthesise a missing pair on demand can still intercept that path; one that does not answers 404 and the device downloads the files it cannot reuse.

The plan is not trusted. It says how to reconstruct a file, never whether the result is acceptable. Reconstruction is driven by the signed manifest's file list, and every output file is verified against its sha256 from that manifest before the bundle is eligible to run. A manifest entry with no corresponding op is an error, and any failure — a missing base, a patch that will not apply, a digest mismatch — falls back to a full download rather than installing something unverified.

Bases are paired per file, not by name: content-hashed chunk names change every release, so the generator picks the base that yields the smallest patch among same-extension candidates and stores that choice in the op. If no base beats simply shipping the file, it becomes an add.

Measured on a real 98-file bundle, a one-word copy change produces a 4,253-byte archive against 1,622 kB for the same release transferred file-by-file.

Release tooling

Everything the device validates, graft also produces. The rules are subtle enough — canonical key order, never re-serialising between signing and upload, pairing patch bases by content rather than by name — that a consumer reimplementing them will get one of them wrong, and the failure surfaces as a signature error on a manifest that was signed correctly.

graft manifest --dir dist --id r-1042 --counter 1042 --native-fingerprint "$(graft fingerprint)" \
               --channel production --not-before 1786238700 --expires-at 1793928700
GRAFT_SIGNING_KEY="$(cat private.pem)" graft sign dist/graft-manifest.json manifest.sig
graft patch --old <previous bundle> --new dist --out r-1041__r-1042.gpz
graft apply --base <previous bundle> --patch r-1041__r-1042.gpz \
            --manifest dist/graft-manifest.json --out <dir>

graft apply is a reference implementation and a conformance check for the native apply paths — it is not what runs on device. What the release pipeline owns is the part graft cannot know: where the files are hosted, which counter a release gets, and when a channel points at it.

Deriving the plugin contract

A bundle can only reach native code through a registerPlugin proxy, so the set of names it passes to registerPlugin is its contract. graftRequires collects them while the bundle is built and writes them out:

import { requiresVite as graftRequires } from '@risemaxi/graft/tools/unplugin.mjs';

graftRequires({ out: 'graft-requires.json' });
graft manifest --dir dist --requires graft-requires.json …

It must run inside the build. Minification rewrites the call sites — a built bundle contains zero recognisable registerPlugin( calls — so there is nothing to scan afterwards.

Two things make a derived contract untrustworthy, and both fail the build rather than warn: a registerPlugin call whose name is not a literal, and first-party code reaching plugins through Capacitor.Plugins. A contract that is too small is worse than none, because the device then accepts a bundle it cannot run.

Only calls in modules importing registerPlugin from @capacitor/core count. The name is not Capacitor's alone — gsap exports one too — and matching on the call alone reports every gsap.registerPlugin(CSSPlugin) as an underivable plugin. The Capacitor.Plugins check is likewise scoped to first-party modules, because Capacitor's own bridge reaches plugins that way by design.

On device the contract is checked against the live bridge — Bridge.getPlugin on Android, bridge.plugin(withName:) on iOS — so it answers from the plugins the running binary actually registered rather than from anything the build recorded. A name the bridge does not know fails the update with The release needs a plugin this build does not have. The check sits in manifest verification, which every path to staging goes through — including the one that reuses a bundle an interrupted install left on disk, where the binary may have been replaced since those files landed. A rejected release neither downloads files nor raises the installed counter, so a later release at the same counter still installs.

As a bundler plugin

The manifest can also be written as part of the build, which removes the second command and with it the chance of signing a stale directory:

import { vite as graftManifest } from '@risemaxi/graft/tools/unplugin.mjs';

graftManifest({ dir: 'dist', id, counter, nativeFingerprint });

unplugin is an optional peer, and the same factory exports rollup, rolldown, webpack, rspack, esbuild and farm builds. It hooks writeBundle and nothing else — the one hook every bundler unifies, and one that deliberately carries no arguments. That suits this exactly: the bundler is being asked for the timing, not for an inventory. A bundler's record of what it emitted is not the shipped file set, because static assets are copied in without passing through it, and every file has to be read off disk to be digested anyway.

Requirements on the consuming app

Every native build must embed a manifest carrying its own fingerprint. graft fingerprint hashes the native inputs — the plugin packages the app declares, and the version-controlled native sources — and the value goes into the manifest shipped inside the binary. That is how the device knows its own identity; there is nothing to configure and no number to keep in step across platforms. A release names the fingerprint it was built against, and a device runs it only when the two are equal, so a bundle can never reach a binary compiled from different native code.

Regenerate the fingerprint whenever the native build is cut, not when the web bundle is built. The two directories a Capacitor app generates, ios/ and android/, are outputs of these inputs rather than inputs themselves, so they are deliberately not hashed — fingerprinting them would make every cap sync look like a native change.

Releases and native builds must be counted on one scale, or the embedded bundle must not claim a counter. On a native release graft compares the embedded bundle's counter against the staged bundle's to decide which web bundle is newer. That only means something if the two numbers come from the same sequence. The simplest way to satisfy it is to derive both from one monotonic source — a timestamp ordinal such as Math.round(Date.now() / 100000) works, and needs no registry.

If your OTA releases are counted independently of your native builds, omit counter from the embedded manifest. Graft then skips the comparison and keeps a staged bundle whenever its fingerprint still matches. Supplying a number that does not compare is the one genuinely dangerous option: an embedded counter that always outranks your release counters makes every store release discard every staged bundle, which is the silent downgrade this rule exists to prevent.

Nothing that changes per release may live inside a hashed chunk

Bundlers name chunks after a hash of their contents, and an update only reuses a file when its name already exists on the device. So a value that changes every release — a crash reporter's release identifier is the usual one — renames the chunk holding it, renames every chunk that references that one, and cascades. Measured on a real app, a one-word source change touching the entry chunk left 19 of 98 files different, 5.74 MB of 9.62 MB (~1.3 MB gzipped). An identifier compiled into that chunk makes you pay the same cost on every release, including ones that changed no code at all.

Graft already knows which release it is serving, so read it from here instead of compiling one in:

import { releaseIdentity } from '@risemaxi/graft';

const identity = releaseIdentity(); // { releaseId, nativeBuild } | null
Sentry.init({ release: identity?.releaseId ?? 'unknown' /* … */ });

It is published to the page before any app code runs and resolves synchronously, so it can be passed straight to an init call. It returns null on the web and on a WebView too old to run a document-start script, so handle that case rather than dereferencing it — on Android the capability is feature-detected, and when it is missing the plugin logs why.

If you must stamp something at build time, put it where the bundler does not hash it — index.html is regenerated every release anyway and costs a few kilobytes.

Downgrades and native releases

counter must increase. The device records the highest counter it has ever installed — at install time, not at boot — so a bundle that fails to boot still raises the floor and the device can only move forward. Rolling back a channel means publishing the old content under a higher counter; a KV flip alone only helps devices that have not taken the release yet, which for a canary is the population that matters.

Capacitor discards its own server path whenever the binary changes, which silently downgrades users to whatever the store build embeds. Graft reads its own pointer and reconciles it deliberately, in the pre-WebView hook: a staged bundle survives a native release when its nativeFingerprint still matches the new build and the embedded bundle's counter is not higher. Bundles with no manifest — anything staged by hand through downloadBundle() — are dropped on any binary change.

Verify

bun run verify:web
bun run verify:android    # needs JDK 21 and ANDROID_HOME
bun run verify:ios        # needs Xcode

Those three only compile. To exercise the apply path on both platforms against a real bundle and a set of deliberately malformed archives:

FIXTURES=<dir> verify/run.sh

The sources it builds are symlinks to the shipping ones, so this runs the code that runs on a device rather than a copy of it. verify/run.sh explains what the fixture directory needs and how to generate it; use a realistic bundle, because a one-file marker exercises none of the base pairing or hash cascade that makes any of this worth testing.

Every malformed archive must be rejected, and none may leave behind a file whose digest is not the signed manifest's. The case that matters most is wrong-content — an op pointed at valid bytes from the wrong file. A corrupted zstd frame fails to decompress before any digest is computed, so it never reaches the check the whole design rests on; only that case does.

Licence

MIT. Copyright (c) 2022 Robin Genz, Copyright (c) 2026 Rise.