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

@scalar/kotlin-fmt

v0.4.1

Published

Kotlin formatter (ktfmt on wasm) - runs on plain Node, no JVM

Readme

Scalar Kotlin Formatter

Version Downloads License Discord

Kotlin formatter that runs on plain Node. No JVM, no java on PATH, no postinstall download.


Scalar is an open-source API platform for teams who want beautiful developer interfaces without vendor lock-in.

  • API References — Interactive API documentation from OpenAPI and AsyncAPI specs.
  • Developer Docs — Write in Markdown/MDX, generate API references, sync with two-way Git.
  • SDK Generator — Type-safe SDKs and CLIs in TypeScript, Python, Go, PHP, Java, and Ruby.
  • API Client — Open-source, offline-first Postman alternative built on OpenAPI.

20M+ monthly npm installs · 15,500+ GitHub stars · MIT licensed · scalar.com


npm i @scalar/kotlin-fmt
import { format } from '@scalar/kotlin-fmt'

await format('fun  f( ) {\nval x=listOf(1,2,3)\n}')
// fun f() {
//   val x = listOf(1, 2, 3)
// }

Async because the first call decompresses the artifact, compiles it and instantiates the module. That work is cached, so every later call is tens of milliseconds.

Options, all defaulting to ktfmt's own values rather than anything we picked:

| Option | Default | ktfmt equivalent | |---|---|---| | style | 'meta' | --meta-style / --google-style / --kotlinlang-style | | maxWidth | 100 | maxWidth | | blockIndent | per style | blockIndent | | continuationIndent | per style | continuationIndent | | trailingCommas | per style | trailingCommaManagementStrategy | | removeUnusedImports | true | removeUnusedImports | | preserveLambdaBreaks | true | preserveLambdaBreaks |

meta is 2-space blocks with 4-space continuations; google is 2 and 2; kotlinlang is 4 and 4. ktfmt's own help says "If none of the style options are passed, Meta's style is used", so that is the default here too.

Source ktfmt cannot parse throws, carrying ktfmt's diagnostic including line and column:

await format('fun f( {')
// Error: com.facebook.ktfmt.format.ParseError: 1:7: error: Expecting ')'

Node 24.15 or newer, and it is a hard floor: below it V8 does not compile the module at all. Not a WasmGC floor — Node 22 has WasmGC — but a question of how V8 types wasm exception handling. TeaVM emits try_table over exnref, and wasm-opt leaves catch_ref branching to a block typed with a non-nullable (ref exn), which is what the spec calls for; V8 used to type it as nullable and reject the module, on Node 22 and on 24.0 through 24.14 alike. JavaScriptCore accepts it throughout, so bun is fine. The package checks and says so rather than failing obscurely; the check compiles a 52-byte probe of that exact shape rather than reading a version number, so bun — reporting a Node version of its own — is judged on what its engine actually does. See packages/java for the long version; both packages are TeaVM output and hit the same thing.

The version it carries

import { ktfmtVersion } from '@scalar/kotlin-fmt'

ktfmtVersion // '0.64'

"Exact against ktfmt" is a claim about a named release, so the release is readable rather than something you look up. It is what a consumer needs to install the matching jar — to verify its own committed bytes in CI, or to pin a container image — and having it here means that number is not maintained twice, once in your repository and once in ours.

src/version.test.ts reads KTFMT_VERSION out of the build script and fails if the two ever disagree, so the export cannot go stale across a bump.

Formatting without awaiting

formatSync is for callers with no await to give — a code generator that formats each file inside the synchronous builder that emits it, a template renderer, a plugin hook that has to return a string.

import { formatSync, init } from '@scalar/kotlin-fmt'

await init()
const formatted = formatSync(source)

Booting is the one thing that cannot be made synchronous, so init covers it once and formatSync throws until it has. Everything after that already was synchronous — format was only ever awaiting the boot, and both produce the same bytes.

Prefer format where you can await: it needs no setup call and cannot throw that error.

It runs in the browser too

The import does not change — bundlers and browsers pick the browser export condition on their own, and format has the same signature and returns the same bytes. Only the wasm's route in differs: fetched rather than read from disk.

import { format, init } from '@scalar/kotlin-fmt'

// Optional. The artifact resolves next to the module by default, which Vite,
// Rollup, webpack and a plain CDN handle unaided. esbuild does not rewrite
// `new URL(..., import.meta.url)`, so there it needs naming.
await init({ url: '/assets/kotlin_fmt.wasm.br' })

await format(source)

Run it in a worker. Booting compiles 3.5 MB of wasm, which is a visibly frozen tab if it happens on the main thread.

The engine floor is checked at boot and is real: the module uses the final wasm exception-handling opcodes, so Chrome 137, Firefox 131 or Safari 18.4 at the earliest.

The browser reads the same brotli artifact as Node (0.82 MB over the wire) and expands it with DecompressionStream('brotli') where the engine has it, or a 208 KB wasm decoder where it does not — Chrome, today. Serving the artifact with Content-Encoding: br, or serving an uncompressed .wasm, skips the decoder entirely:

await init({ url: '/assets/kotlin_fmt.wasm', encoding: 'none' })

This is the real ktfmt, and the output is exact

This is actual ktfmt 0.64 — including the Kotlin compiler's own PSI parser, which is what it uses to read Kotlin — compiled to WebAssembly by TeaVM. It is not a reimplementation, so it does not drift.

The claim is checked in two halves, because there are two places output could change, and both are byte-for-byte over the same 589 real Kotlin files from kotlin-stdlib, kotlinx-coroutines and ktfmt itself, in all three styles:

| | What it asks | Result | |---|---|---| | build/java_fmt_teavm/kotlin-probe/gate2.sh | does the one patched file change ktfmt's output, on a JVM? | 1767/1767 identical, diagnostics included | | build/java_fmt_teavm/kotlin-probe/conformance.sh | does compiling that to wasm change it? | 1767/1767 identical |

Together those chain: stock ktfmt on a JVM → patched ktfmt on a JVM → this module. Failures are compared too, encoded the same way on both sides, so a reworded diagnostic would count as a divergence.

The one patched file, and why it does not affect that claim

ktfmt opens a KotlinCoreEnvironment to parse. That is the compiler's CLI environment: it resolves a JVM classpath, opens jars, and registers extension points named by string in an XML descriptor. An ahead-of-time compiler cannot follow any of that, and parsing does not need it.

So Parser's initializer — 20 lines of one file — builds the smallest container that can hold a PSI file instead, which is the same one KotlinCoreEnvironment builds underneath. Everything below it is ktfmt's, unchanged: the same LightVirtualFile with the same name, and the same PsiManager lookup, so the tree that comes out is the tree ktfmt formats. gate2.sh is the evidence, and the diff is build/java_fmt_teavm/patches/ktfmt.patch.

TeaVM is patched too

Six patches, five of them ordinary bug fixes or missing class-library members, and one a miscompile — ClassInitElimination treated any invocation as initializing the class it names, which deleted a class initializer that a later read of the same class's static field depended on. They are listed in build/java_fmt_teavm/patches/README.md; none has landed upstream yet.

One group is a deliberate divergence rather than a fix: Thread.yield, Thread.sleep and Object.wait are stand-ins that do nothing or throw, because this module is single-threaded. They are also what makes it small — a single Thread.yield() reachable in kotlinx-coroutines made a third of the program a coroutine and the module 37% larger.

LockSupport.park is one of those stand-ins, and it used to be audible. Opening ktfmt's parser builds an IntelliJ CoreProjectEnvironment, which launches two coroutines and so starts kotlinx-coroutines' scheduler; its workers park waiting for work, the stand-in throws, and TeaVM's default handler wrote the resulting stack trace to console.error — once per process, on the timer turns after the first format had already resolved. Formatting was correct throughout. The module now installs a handler that drops exactly that — an UnsupportedOperationException whose message is one of the stand-ins refusing, "would block the only thread" or "cannot be satisfied with one thread" — and prints everything else, because those coroutines are real and a genuine failure on one is the only way it would ever be seen. packages/kotlin/test/quiet.test.ts formats in a child process and asserts its stderr is empty, so if TeaVM rewords a refusal the noise comes back rather than the silence spreading.

Building it

bun run kotlin:build

Needs a JDK 21, Maven, git and Node; it fetches TeaVM, ktfmt, Binaryen and the repo's pinned Node into a gitignored toolchain/. TeaVM's output then goes through wasm-opt -O3, which takes the artifact from 0.91 MB to 0.82 MB and formats about 14% faster; conformance.sh is what says it changed no output. See packages/java for why that pass was skipped until recently — it was a V8 bug, not a Binaryen one. Around 15 minutes cold, most of it compiling TeaVM. The artifact is committed, so this only needs rerunning when a pin or a patch changes.

Community

We are API nerds. You too? Let's chat on Discord: https://discord.gg/scalar

License

MIT, for the TypeScript in this package.

The wasm artifact embeds ktfmt, the Kotlin compiler's PSI, google-java-format's layout engine, Guava and TeaVM's class library — all Apache-2.0. See licenses/NOTICE.md.