Compiling Scripts
ktx compile packages a script into a standalone fat jar that runs on plain JRE 17+. End users do not need ktx, do not need a Kotlin compiler, do not need any extra setup.
Quick example
$ ktx compile samples/hello.main.kts
[INFO] compile /path/to/samples/hello.main.kts
[INFO] merge: 3 KB script artifact + 0 user deps + 4 Kotlin runtime jars + ktx core
[INFO] pack done: samples/hello.jar (7501 entries)
Output: samples/hello.jar (11288 KB)
Run: java -jar samples/hello.jar [args...]
$ java -jar samples/hello.jar foo bar
hello from main.kts
args (2):
[0] foo
[1] barWhat's in the jar
The merged jar contains exactly the classes needed at runtime:
This separation is what brings the size down to 11–13 MB instead of ktx's full 80 MB install.
Sizes and startup
| Script | Output size | java -jar startup |
|---|---|---|
hello.main.kts (no deps) | 11 MB | ~180 ms |
jackson.main.kts (jackson-databind + 3 transitives) | 13 MB | ~220 ms |
vs. ktx run first invocation of jackson.main.kts: 6.7 s.
The compile cost lands once, on your build machine. End users pay only the java -jar cost.
Side effects
ktx compile runs the script body once during compilation, because dependency resolution in kotlin-main-kts is interleaved with class generation. If your script has side effects you don't want to fire during build (HTTP calls, file writes, etc.), wrap them in a main function or guard them with an explicit "compile mode" flag.
// avoid: this network call fires during ktx compile
val data = HttpClient().get("https://api.example.com").body<String>()
println(data)
// prefer: keep side effects under main
fun main() {
val data = HttpClient().get("https://api.example.com").body<String>()
println(data)
}
main()Future versions may add a --no-execute flag that skips evaluation entirely; tracked in the Phase 3 TODO.
What gets included automatically
ktx compile analyzes your script's @file:DependsOn and @file:Repository declarations the same way ktx run does. Every transitive jar that resolution produces is packaged.
Resources (non-class files in jars) are also packaged: META-INF/services/* files are concatenated for ServiceLoader compatibility, module-info.class files are dropped to keep the jar usable on the classpath.
What is not included
kotlin-compiler-embeddable.jar(~60 MB). The script is already compiled; the runtime needs no compiler.main-kts.jar. Its sole purpose is to drive resolution and compilation; both happen at compile time.- ktx's CLI / daemon / protocol modules.
This is what gives ktx compile output its 1/6 size advantage.
When to use ktx compile
- Distribute internal tools. Hand a coworker a 13 MB jar instead of asking them to install ktx and Kotlin.
- Build artifacts in CI. Embed
ktx compilein a build pipeline; ship the jar with your release. - One-off scripts on production. Schedule a cron job that runs
java -jar your-script.jar; no ktx footprint on the host.
When not to use it
- Active development. Use
ktx run --daemonfor the dev loop; recompile-to-jar every iteration is overkill. - Scripts that change every run.
ktx compiledoes not run a daemon; it shells out a fresh compiler per invocation.
Self-contained: zero Java required
--self-contained wraps the fat jar in a directory that ships its own minimal JRE. End users do not need Java installed at all — they extract the directory and run bin/<name>.
$ ktx compile samples/hello.main.kts --self-contained -o ./hello-app
[INFO] compile /path/to/samples/hello.main.kts
[INFO] modules: detected=[java.base, java.logging, ...], final=[java.base, ...]
artifact: ./hello-app/ (~42 MB)
layout:
bin/hello-app launcher
lib/app.jar compiled script (~11 MB)
runtime/ embedded JRE (~31 MB)
run with: ./hello-app/bin/hello-app [args...]
$ ./hello-app/bin/hello-app foo bar
hello from main.kts
args (2):
[0] foo
[1] barUnder the hood:
- Build the fat jar (same as the default path above).
jdeps --print-module-depsreads the fat jar and reports the JDK modules it actually uses.jlink --add-modules <list> --compress=2 --strip-debugproduces a minimal runtime underruntime/.- A small launcher script (
/bin/shon macOS / Linux,.baton Windows) chains the bundledjavatolib/app.jar.
A few extra modules — jdk.unsupported, java.naming, java.logging — are unioned in unconditionally because jdeps cannot trace classes loaded reflectively, and they are cheap to keep.
Trade-offs
- Size: ~40 MB for a no-deps script, ~60 MB once a database driver or large JSON library lands. About 4× the fat jar but absolutely flat for the end user.
- Host platform only:
jlinkcannot cross-compile. To distribute to macOS, Linux, and Windows, runktx compile --self-containedonce per platform. - Build host needs a JDK:
jdepsandjlinkship with the JDK, not the JRE. ktx will exit with a clear error if either is missing.
When to use it
- Tools for non-developer audiences who balk at "first install Java".
- Locked-down hosts where you cannot install or update a system JRE.
- Reproducible artifacts: the JRE version is pinned to whatever JDK ran
ktx compile.
Native binary: zero Java, single file
Experimental — The
--nativeflag is still early. Many scripts will fail to build due to incomplete reflection metadata. Only simple scripts (no deps, or jackson-based) are known to work. Expect iteration as the metadata surface expands.
--native produces a single executable file via GraalVM native-image. End users get one binary they double-click or chmod +x and run; no JRE, no fat jar, nothing else.
$ ktx compile samples/hello.main.kts --native --collect-metadata
compiling hello.main.kts -> samples/hello (native binary)
note: --collect-metadata will execute the script once under JVM agent before native-image runs.
[INFO] native-image: /Users/.../bin/native-image
[INFO] fat jar built: /tmp/.../app.jar (11315 KB)
[INFO] running fat jar under native-image-agent (this executes the script body)...
hello from main.kts
[INFO] script metadata persisted to samples/hello.main.kts.native-meta
[INFO] native-image build (this takes 30-180s)...
... <native-image progress bar> ...
artifact: samples/hello (~38 MB)
run with: samples/hello [args...]
$ time ./samples/hello arg1 arg2
hello from main.kts
args (2):
[0] arg1
[1] arg2
real 0m0.022sHow --collect-metadata works
GraalVM analyses your fat jar at build time and emits a binary that excludes everything it cannot prove reachable. Reflection, dynamic class loading, JDK serialisation and resource lookups are invisible to that analysis, so they need to be declared explicitly via JSON metadata. ktx supplies three layers:
- Built-in base — covers the Kotlin scripting host's own reflection paths (
KtsScript,KJvmCompiledScript,META-INF/services/...,kotlin_builtinsresources). Always applied. - GraalVM Reachability Metadata Repository (GRMR) subset — bundled inside ktx for popular libraries like jackson. When your script declares
@file:DependsOn("com.fasterxml.jackson.core:jackson-databind:..."), ktx finds the matching entry and feeds it to native-image automatically. - Per-script trace —
--collect-metadataruns the fat jar once on the JVM withnative-image-agentattached, capturing every reflection / resource / serialisation event. The recording lands in<script>.native-meta/next to the source. Subsequent builds re-use that directory without re-running the agent.
Once <script>.native-meta/ exists, ktx compile --native <script> (no flag) is sufficient — and the directory is safe to commit to source control to make builds deterministic across machines.
What gets distributed
A native binary is completely self-contained. The metadata JSON only matters at build time; everything reachable is compiled into the binary itself. End users receive nothing but the binary and run it.
| Artifact | Required to run | Size |
|---|---|---|
| Fat jar (default) | JRE 17+ | ~11–13 MB |
| Self-contained dir | nothing | ~40–60 MB |
Native binary (--native) | nothing | ~38–41 MB |
Trade-offs
- Build cost is high: 30–180 s on a modern laptop, peak RSS ~3 GB. Don't run
--nativeon every code change. Use it for releases. - Host platform only: like
jlink,native-imagedoes not cross-compile. To distribute to macOS, Linux, and Windows, runktx compile --nativeonce per platform (typically in CI matrices). - Build host needs GraalVM: install via
mise use -g java@oracle-graalvm-21.0.7(recommended) or any other GraalVM 21+ distribution. ktx looks fornative-imageunder$GRAALVM_HOME/bin, then$JAVA_HOME/bin, then onPATH. --no-fallbackis mandatory: ktx always passes--no-fallbackso missing reflection metadata fails the build instead of producing a bloated fallback image. Re-run with--collect-metadataif a previously unrecorded reflection path appears.- Side effects fire during
--collect-metadata: the agent runs your script body to record what it does. If the script makes HTTP calls or mutates state, that happens once at build time — same caveat as the fat-jarcompileflow.
When to use it
- Distributing CLI tools to end users who don't have any Java setup at all.
- Performance-critical short-lived scripts where the ~10 ms startup of a native binary matters versus ~200 ms for
java -jar. - Embedding scripts inside container images where size and start-time both matter.
When not to use it
- During development. Fat jar (
ktx compile) or daemon mode (ktx run --daemon) are dramatically faster for the dev loop. - For scripts whose reflection surface changes often. The
--collect-metadatarecording captures one execution path; if your script branches on data and calls reflection conditionally, run--collect-metadataenough times to cover every branch (the agent merges runs in<script>.native-meta/).
Further reduction (planned)
| Technique | Phase | Expected size |
|---|---|---|
| Fat jar (Phase 2.4) | shipped | 13 MB |
--self-contained (Phase 3.1) | shipped | 40-60 MB total, zero user deps |
--native (Phase 3.2) | shipped | ~38-41 MB native binary, ~10-25 ms startup |
| ProGuard / R8 reflect-only keeps | Phase 3+ | ~10 MB |
jpackage installer (.dmg / .deb / .exe) | Phase 3+ | wraps --self-contained |
The jpackage installer mode is tracked in the Phase 3 TODO.