Library / SDK
cashapp/zipline avatar
cashapp/zipline

cashapp/zipline: shipping Kotlin/JS code to a Kotlin host without waiting for app store review

Run Kotlin/JS libraries in Kotlin/JVM and Kotlin/Native programs

2,304 stars209 forksCApache-2.0

At a glance

What is it?
Zipline is an Apache-2.0 Kotlin Multiplatform library that embeds the QuickJS engine inside a JVM or Native program, so a host can download compiled JavaScript modules and call them through a shared interface. It signs its manifests, integrates Kotlin source maps, and explicitly does not offer a sandbox, which is the constraint that decides whether the plugin story is safe for you.
Who is it for?
Zipline earns its place in a Kotlin shop that owns both ends of the bridge: an interface in shared code, an implementation compiled to JavaScript, and a host that can verify a signature before loading it. That combination makes it a good fit for business rules, trivia content, or pricing logic that changes faster than store review, and a good fit for a Gradle build that already produces Kotlin/JS.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly C, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The premise: fetching code should look like fetching data

Zipline's stated goal is to make loading code as unremarkable as loading data, and the reason it exists is a gap in mobile release mechanics. Continuous deployment works for servers and web apps because a deploy is a deploy. For a mobile app, the equivalent path runs through app store review, which the project calls out as too slow to depend on when a user's device must pick up a change immediately. Every other reason for running code in a host process follows from the same frustration, and the project lists them: user customizable behaviour and plugin systems, business rules such as pricing or payments, and fresh content like games.

The mechanism is a real JavaScript engine rather than an interpreter layer over something else. QuickJS is embedded in the Kotlin/JVM or Kotlin/Native program, and the choice is justified on its own terms as a small, fast engine suited to embedding in applications. The library's positioning is deliberately modest about what it adds: it connects Compose style state, or here Compose style nothing, and glues the engine to kotlinx.coroutines so Kotlin/JS libraries can be used from Kotlin code.

A detail in the repository listing explains something about the project. The primary language is recorded as C, and there is a shell script at the root whose name is `update_quickjs.sh`. This is a Kotlin project that vendors an upstream C engine and keeps it in step with upstream releases. Knowing that is useful, because it tells you the engine is a tracked dependency with its own update cadence rather than something the project forked and diverged.

The trivia sample walks the whole path in four steps

The worked example is a trivia game that serves fresh questions daily to users who never update the app, and it is worth following end to end because each step corresponds to a decision you will also have to make.

First, the interface goes in shared code so both sides can see it:

kotlin
interface TriviaService : ZiplineService {
  fun games(): List<TriviaGame>
  fun answer(questionId: String, answer: String): AnswerResult
}

Second, the implementation is written in the JavaScript source set, where it can be a normal Kotlin class with no bridging ceremony. Third, an exported function in that same source set binds the instance to a name so the host can find it later:

kotlin
@JsExport
fun launchZipline() {
  val zipline = Zipline.get()
  zipline.bind<TriviaService>("triviaService", RealTriviaService())
}

Fourth, the host loads. A development server serves the compiled output, and the served manifest is a single JSON document listing every module the application needs:

console
./gradlew -p samples trivia:trivia-js:serveDevelopmentZipline --info --continuous

The loader takes that manifest URL, a manifest verifier, an HTTP client and a dispatcher:

kotlin
suspend fun launchZipline(dispatcher: CoroutineDispatcher): Zipline {
  val manifestUrl = "http://localhost:8080/manifest.zipline.json"
  val loader = ZiplineLoader(
    dispatcher,
    ManifestVerifier.NO_SIGNATURE_CHECKS,
    OkHttpClient(),
  )
  return loader.loadOnce("trivia", manifestUrl)
}

Two things in that snippet deserve attention. The loader is given a name and told to load once, which implies a lifecycle where repeated loads reuse what is already resident. And the dispatcher is a parameter, not an implementation detail, with the requirement stated plainly: it must be single threaded, because each Zipline instance is confined to one thread. That constraint propagates into your architecture, since a host with a shared multi threaded pool has to carve out a dedicated one.

Pass by value by default, pass by reference only for services

The bridging rules are stated with the specificity you want, because getting them wrong produces silent copies rather than errors.

Arguments and return values are pass-by-value by default, encoded and decoded with kotlinx.serialization. Anything that is not a bridged service type crosses as data. A bridged interface is one that extends `ZiplineService`, which is a marker that also requires a single `close()` method for releasing held resources, and instances of those types are pass-by-reference instead, meaning the peer can call methods on a live object rather than on a snapshot of one.

That marker has a cost, and the project is honest about it. After using a bridged interface it must be closed so the peer object can be collected, and the documentation singles this out as difficult to get right. Rather than leaving it to discipline, Zipline borrows the approach LeakCanary uses and aggressively detects a missed `close()`. That is a good design decision for a library whose failure mode is a memory leak in a long-lived mobile process, and it is also a hint about the intended usage: a small number of long-lived service objects, not a service per call.

The direction of the bridge is not fixed either. The documentation describes defining an interface in shared code, implementing it in JavaScript and calling it from the host, and also the reverse, implementing on the host and calling from JavaScript. For a plugin architecture that means the host can expose capabilities to the plugin, which is the shape you need if the JavaScript is allowed to ask questions rather than only answer them.

Suspending functions, setTimeout, and Flow across the boundary

Two capability claims are made about the bridge, and they are what turn it from a function call mechanism into something you can build reactive systems on.

The first is that interface functions may be suspending, and internally Zipline implements `setTimeout()` so asynchronous code behaves the way it is supposed to in Kotlin/JS. This sounds like a small detail and is not. JavaScript's asynchrony is callback and timer based, Kotlin's is coroutine based, and an engine with no event loop integration will either block the single thread it is confined to or silently reorder your continuations. The project handles that by supplying the timer, which is the piece that would otherwise force you to wrap every call site in a callback.

The second is that `Flow<T>` is supported as a parameter or a return type. Given the pass-by-value default, a Flow crossing the boundary is a stream of encoded values rather than a live reference, and that distinction determines what you can build. A host emitting a Flow of state into a plugin gives the plugin a subscription it can collect, and a plugin returning a Flow gives the host a stream of results to render. Neither is a live object graph, which is a good property: it means a misbehaving plugin cannot retain a host reference indefinitely through a callback, only through a service it was handed and then failed to close.

Combined with the source map integration, the developer experience story is coherent. If the process crashes, the stack trace prints Kotlin files and line numbers, so a stack that crosses the boundary is readable rather than a wall of generated JavaScript. And `console.log` is forwarded to the host platform, using the Android logging API on Android, the standard Java logging facility on the JVM, and standard output on Kotlin/Native, which means a plugin developer writing ordinary logging statements does not need to learn the host's logging conventions.

Precompiled bytecode, concurrent modules, and a sampling profiler

Two bottlenecks are named explicitly, and each has a specific answer rather than a general claim about speed.

The first is compilation. Embedding a JavaScript engine means every launch pays for parsing and compiling the source, and on a mobile device that cost is visible as startup latency. Zipline addresses it by precompiling JavaScript into QuickJS bytecode, so the engine loads a preprocessed artifact instead of parsing text.

The second is the network. The answer is a modular application model, and it is designed around a specific asymmetry that most teams actually have. Each input module, and the examples given are Kotlin's own standard library, serialization and coroutines packages, is downloaded concurrently rather than one after another. Each downloaded module is cached separately. Modules can optionally be embedded into the host application so that a launch with no network at all still works. And if your application code changes more often than the libraries underneath it, users download only what changed, which is the same incremental story a JavaScript bundler gives you and just as important on a metered mobile connection.

The profiler is the third entry and it is framed honestly. If you hit performance problems inside the QuickJS runtime, Zipline includes a sampling profiler that breaks down where CPU time is going. That is a narrower promise than general performance tooling, and for a library whose main risk is that a large untrusted module eats a frame budget it should not have, knowing whether the time is in your host code or in the guest is the question that matters.

Manifest signing, key rotation, and a dev mode that skips it

Since the host downloads and executes code, authenticity is not optional, and the project treats it as a build server concern with a host side check.

Two signature algorithms are supported, EdDSA with Ed25519 and ECDSA with P-256. Generating a key pair is a Gradle task installed by the Zipline plugin, which prints the algorithm and both halves of the key:

console
./gradlew :generateZiplineManifestKeyPairEd25519

The private key stays on the build server, and the Gradle configuration names each key and records which algorithm it uses, so a project can hold more than one at a time:

kotlin
zipline {
  signingKeys {
    create("key1") {
      privateKeyHex.set(...)
      algorithmId.set(app.cash.zipline.loader.SignatureAlgorithmId.Ed25519)
    }
  }
}

The public key goes into the host, and the verifier is built from named keys:

kotlin
val manifestVerifier = ManifestVerifier.Builder()
  .addEd25519("key1", ...)
  .build()
val loader = ZiplineLoader(
  manifestVerifier = manifestVerifier,
  ...
)

Because keys are named on both sides, both signing and verifying accept multiple keys, and that is what makes rotation possible without a flag day where every installed host rejects the new build. It is the same reason a certificate authority has two keys at once.

One thing to notice in the sample code from earlier: the development loader passes `ManifestVerifier.NO_SIGNATURE_CHECKS`. That is correct for a localhost development server and catastrophic anywhere else, and the fact that it is a single named constant rather than a nullable argument is a small piece of good API design, because it makes the insecure choice visible in code review.

The plugin use case and the no-sandbox statement cannot both be read naively

The project lists user customizable behaviour and plugin systems as a reason to exist, and then closes its README by saying it is designed to run your organization's code, when and where you want it, and that it does not offer a sandbox or process isolation. Those two statements have to be reconciled, and the reconciliation is the most important thing to understand before adopting the library.

Read together, they say the security boundary is the signing key. That is a real boundary and it is sufficient for one deployment shape: code your organization compiled, signed with a key your build server holds, and shipped through a manifest the host verifies before loading. Against a compromised build server or a leaked private key, an attacker gains JavaScript execution inside your process, but not the ability to run anything your own build did not already ship.

It is not sufficient for the other reading of the word plugin, the one where JavaScript comes from users, from a community, or from any source your build server did not sign. There is no isolation to contain that code, no process boundary to kill, and no resource limit mentioned as a feature. A guest that loops will consume the single thread it was confined to, and that thread is the one your host runs Zipline on. Anyone evaluating Zipline for a third party plugin marketplace should treat this line in the README as disqualifying unless they add a boundary of their own, such as running the Zipline instance in a separate process they can kill, and the single threaded dispatcher constraint makes that separation awkward rather than impossible.

The honest framing is that Zipline is a content delivery mechanism with a signing scheme, not a extensibility sandbox. The four use cases it lists sit comfortably inside that framing. Remote plugin execution for untrusted authors sits outside it.

A fourteen module repository with two Gradle plugins

The top level listing shows how much of this is build tooling rather than library code, which is worth knowing before you go looking for the main class.

Two Gradle plugins ship from here. One is the Gradle plugin, which installs the key generation task and the signing configuration block used above. The other is a Kotlin compiler plugin, and its presence is the strongest signal about the project's direction. A compiler plugin is how you make a feature of the library invisible at the call site, and its existence alongside a Gradle plugin means the project can change what compiles rather than only what runs.

Around those sit focused modules that map one to one onto the concerns above: a bytecode module, a cryptography module for the signature algorithms, a loader module, a profiler module, an API validator, a command line tool, an Android NDK module, and separate testing modules including a loader testing module and a Kotlin plugin test suite. There is a `kotlin-js-store` directory, which is the lock file for the npm side of a Kotlin/JS build, and a `samples` directory containing the trivia sample and a second one built around a world clock, plus a keystore, so the samples are set up to be built rather than merely read.

The supporting files round out the picture. There is a changelog, a releasing document, a troubleshooting document, and a blame ignore file, which indicates that a formatter or codemod has rewritten history at least once and the project wanted future blame to stay useful. A lint configuration sits at the root. The default branch is named `trunk` rather than `main`, and the last push is dated 2026-09-28 against a most recent tagged release of 1.27.0 from 2026-04-02, so there is active work on trunk that has not been cut into a release in close to six months. For a library embedding a C engine, that gap probably reflects engine updates, and the `update_quickjs.sh` script is where you would expect them to land.

Editorial conclusion

Zipline earns its place in a Kotlin shop that owns both ends of the bridge: an interface in shared code, an implementation compiled to JavaScript, and a host that can verify a signature before loading it. That combination makes it a good fit for business rules, trivia content, or pricing logic that changes faster than store review, and a good fit for a Gradle build that already produces Kotlin/JS. It is the wrong tool the moment the JavaScript comes from your users rather than from you, because the library states outright that it provides no sandbox and no process isolation, so the execution boundary it gives you is a signing key and nothing else. Verify first that a single threaded dispatcher is available for the host, since that constraint is not negotiable, and measure the module cache behaviour before assuming an offline launch works.

Frequently asked questions

What problem does cashapp/zipline solve?

It lets a Kotlin/JVM or Kotlin/Native program load and run Kotlin/JS code at runtime, so content or business logic can be updated without an app store release. The stated motivations include continuous deployment, user customizable behaviour, changing business rules such as pricing, and fresh content.

How does Zipline authenticate the JavaScript it downloads?

Manifests are signed with EdDSA Ed25519 or ECDSA P-256 keys, generated by a Gradle task, kept private on the build server and configured in the signing block. The host builds a verifier with the public keys, and both sides accept multiple named keys so the signing key can be rotated.

What is the single threaded dispatcher requirement in Zipline?

Each Zipline instance must be confined to a single thread, so the dispatcher passed to the loader has to be single threaded. In the samples the Gradle task for the development server is expected never to reach completion, and it is run in a separate terminal from the host program.

Does Zipline sandbox the JavaScript it runs?

No. The documentation says it is designed to run your organization's code and does not offer a sandbox or process isolation. The trust boundary is the manifest signature, so code from your users or any unsigned source is outside what it is built to handle.

How are values passed across the Zipline bridge?

Arguments and return values are pass-by-value using kotlinx.serialization. Interfaces that extend ZiplineService are passed by reference so the peer can call methods on a live instance, and those interfaces must define close() to release the peer's resources, with missed calls detected aggressively.

Official sources

  1. cashapp/zipline on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/cashapp-zipline.svg)](https://hysenlabs.com/projects/cashapp-zipline)