Somewhere between the third config file and the second build step, I stopped having fun.
It crept up on me slowly. The work I actually wanted to do, writing code that solves a problem, kept getting pushed further and further behind the work of getting ready to write that code. Scaffolding, configuring, wiring tools together, then maintaining all of it. I have been writing JavaScript since 1999, so I had collected a lot of opinions about what was wrong and very little to show for them.
So I built the runtime I always wanted to use. Three years ago I took the decision to start, in 2024 I sealed the name and took the domain, and now, in the middle of 2026, EkkoJS is finally here, at ekkojs.com.
A fresh start
If I am going to build a new runtime, then I am going to start fresh. No dragging the past along just to make a few people feel at home on day one. Every decision below is one I made on purpose, knowing it would cost me some compatibility, because the whole point was to stop paying for the old mistakes.
Bye bye CommonJS
The future is modular, so let's cut the bridge with the past. EkkoJS is, and stays, ESM only. No compromise.
Yes, that alone walks away from the CommonJS world, and from the easy interop everyone else keeps alive. I am fine with that. The two module systems were never meant to live together. CommonJS loads synchronously, ESM loads asynchronously, and that single difference is why every runtime that supports both ends up with a pile of special cases to paper over the seam.
I did not want that pile. One way in, one way out. A static import, top to bottom.
import { readText } from "ekko:fs"; // built-in module, ekko: prefix
import { connect } from "ekko:db/orm"; // built-in, sub-path
import { Button } from "@ekko/asgard"; // a package from the store
import { formatUser } from "./lib/user"; // local file, extensionless
// No require(), no module.exports, no __dirname. There is nothing else to learn.
export function greet(name: string) {
return `Hello, ${name}`;
}
Imports are always extensionless. Whether the file is .js, .ts, .jsx, .tsx or .mjs, you import it by name and the runtime resolves the rest. The runtime enforces it at build time, so writing the extension is an error. One rule, no exceptions, nothing to argue about in a review.
And when you genuinely need to load something at runtime, dynamic import() is there too, same rules, same resolution.
// load it only when you actually need it
const { runMigration } = await import("./db/migrate");
Bye bye node_modules
Here is the one most people will trip over, so let me be direct. EkkoJS does not have a node_modules. Dependencies do not get copied into your project folder, ever.
When you add a package, it goes into a single local store on your machine, one copy per version, shared by every project that asks for it.
ekko add @ekko/asgard # resolves, downloads, installs into the store
ekko add @ekko/asgard@1.0.0 # a specific version
ekko add --build some-tool # a build-only dependency
Your project keeps a short list of what it depends on in ekko.json, and the exact versions are pinned in ekko.lock. That is it. No folder with thousands of nested directories, no copy of the same library sitting in fifteen different projects, no waiting for the disk to finish writing a small country's worth of files before you can start.
The mental model is simple. Your project declares what it needs. The store holds the bytes. The same version is installed once and reused everywhere, instead of being copied so many times the folder grows its own gravity.
TypeScript is a first class citizen
TypeScript is a first class citizen here, treated exactly like JavaScript, with no configuration needed.
There is no tsconfig.json to write, no compiler to install, no build step to wire up before you can run a single line. You point EkkoJS at a .ts file and it runs.
ekko run main.ts
ekko run main # the extension is optional, same as everywhere else
You can mix .ts and .js in the same project without thinking about it, because imports are extensionless and the runtime resolves the right file for you. Types are there while you write, and they get out of the way when you run.
We never had to configure JavaScript to make it run. With EkkoJS, you do not configure TypeScript either. It just runs.
More than a runtime
A runtime that only runs scripts gets dull fast. What I wanted was a single tool that could build the things I actually build, without dragging in a different stack for each one.
So EkkoJS ships the targets out of the box, all from the same runtime, the same language, and the same store:
- Libraries. Write a package, pack it into a single file, share it. The thing you build is the thing other projects depend on.
- Web, full stack. React on the front, a real backend behind it, SEO handled. This is Rune, and it gets its own section below.
- CLI. Real command line tools, with argument parsing, options and colored output, in a handful of lines.
- TUI. Rich terminal applications with panels, layouts and live updates.
- Desktop. Native desktop applications, with no Electron and no rebuilding all your native dependencies just to ship a window.
All of this comes from the runtime itself, not from a pile of third party packages glued together and prayed over. One install, and you can reach for any of these the moment you need it.
One file to ship: the .ekl
When a project is ready to leave your machine, you pack it.
ekko pack
# → my-project-1.0.0.any.any.ekl
That single .ekl file is the whole project: your transpiled sources, the native libraries it needs, the assets it reads, and the ekko.json and ekko.lock so it resolves its dependencies exactly the way it did on your disk. Everything is compressed per entry, checksummed with SHA-256, and optionally signed with ECDSA. One file to ship, one file to run, and a dist folder you never have to babysit.
A runnable project starts straight from the archive, with no extraction step:
ekko run --ekl my-app.ekl --allow=all
An .ekl is more than something you publish to a registry. Anything you can run in EkkoJS runs from one just as well, a CLI tool, a server, a full Rune site, all from that single file.
The .ekl is the filesystem
Which leads to the part I find elegant. When an app runs from its .ekl, the archive becomes its filesystem.
Every ekko:fs read resolves against an in-memory virtual filesystem built from everything you packed. Reads look in that VFS first and only fall back to the real disk if the path was not packed. So the app always finds its own bundled assets, wherever you copied the file, and reading its own resources needs no fs permission, because it is reading itself, not the host's disk.
Writes are the mirror image. They always go to the real filesystem, rooted next to the archive, so the app can read its sealed contents and still write where it is allowed to. The bundle stays read-only and honest, and the host stays in control of what gets touched.
A rich standard library
Like any runtime worth using, EkkoJS ships a deep standard library, so you can start building immediately instead of shopping for a dependency every time you need to hash a string or open a socket. Every module lives behind the ekko: prefix, and every one of them answers to the permission system.
Here is the surface, grouped by what you would reach for.
System and I/O
| Module | What it gives you |
|---|---|
ekko:fs |
File system access, synchronous and CWD-relative |
ekko:fs/path |
Path building and normalization, cross-platform |
ekko:net |
Raw TCP and UDP sockets and DNS, client and server |
ekko:process |
Spawn child processes, read env, args, signals, exit codes |
ekko:ffi |
Call into native C-ABI libraries directly from JS or TS |
Data, text and time
| Module | What it gives you |
|---|---|
ekko:crypto |
Hashing, AES and RSA encryption, key derivation, ECDSA signatures, secure random |
ekko:compress |
Compression and decompression, gzip, deflate and brotli |
ekko:datetime |
Dates, durations and timezones with real IANA support |
ekko:image |
Image decoding, encoding, resizing and transformation |
ekko:text/json |
JSON parsing and serialization beyond the built-in JSON
|
ekko:text/encoding |
Base64, hex and UTF-8 encoders and decoders |
ekko:text/regex |
Regular-expression helpers |
Databases
| Module | What it gives you |
|---|---|
ekko:db |
Low-level access across SQLite, Postgres, MySQL, SQL Server and Mongo |
ekko:db/orm |
A typed query builder over the same drivers, swap the driver without rewriting your queries |
Web and real time
| Module | What it gives you |
|---|---|
ekko:web |
HTTP and WebSocket server with security middleware built in |
ekko:web/validate |
Schema validation for bodies, params and queries |
ekko:web/graphql |
A GraphQL server that plugs into ekko:web
|
ekko:web/realtime |
Real-time channels and rooms over WebSocket |
Auth and access control
| Module | What it gives you |
|---|---|
ekko:auth |
Sessions, JWT and password hashing |
ekko:auth/rbac |
Role-based access control, roles, permissions and policy checks |
Full stack (Rune)
| Module | What it gives you |
|---|---|
ekko:rune |
The full-stack framework, createApp, pages and middleware |
ekko:rune/router |
SSR-first then client-side routing, Link and useRouter
|
ekko:rune/mimir |
Mimir, the cross-frontier state management |
ekko:rune/seo |
SEO helpers, canonical URLs, sitemap and robots |
ekko:ssr |
The standalone server-side rendering engine |
ekko:ssr/css |
Scoped Sass and CSS modules for server rendering |
Application targets
| Module | What it gives you |
|---|---|
ekko:app/cli |
CLI framework, command parser, colored output and prompts |
ekko:app/tui |
TUI framework for rich terminal applications |
ekko:app/gui |
GUI framework for native desktop applications |
Jobs, logging and testing
| Module | What it gives you |
|---|---|
ekko:job/cron |
Cron-style scheduled jobs |
ekko:job/queue |
Background job queues with retries |
ekko:log |
Structured logging with levels and formats |
ekko:test |
The built-in test runner, describe, test, expect, mocks and coverage |
ekko:test/assert |
A standalone assertion library |
On top of all of these sits the global Ekko namespace for the runtime itself, with Ekko.spawn, Ekko.parallel and Ekko.Channel for threading, plus Ekko.args and Ekko.env.
Native code, the easy way
Calling into a native library should be boring, so in EkkoJS it is. You point ekko:ffi at a shared library, declare the functions you want with their argument and return types, and call them like any other function.
import { dlopen, types } from "ekko:ffi";
// The C math library has a different name on each OS, so pick it from Ekko.platform.
const MATH = { win32: "msvcrt.dll", darwin: "libSystem.B.dylib" }[Ekko.platform] ?? "libm.so.6";
const libm = dlopen(MATH, {
sqrt: { args: [types.f64], returns: types.f64 },
pow: { args: [types.f64, types.f64], returns: types.f64 },
});
console.log(libm.sqrt(144)); // 12
console.log(libm.pow(2, 10)); // 1024
libm.close();
That is the whole ceremony. The runtime handles the conversions across the boundary, numbers, strings, buffers and pointers, and FFI still answers to the permission system, so a package can call its own bundled binary while anything external stays behind a deliberate, named grant.
And here is where it pays off on the desktop. Anyone who has built a desktop app on the usual stack knows the native rebuild dance, where the moment Node and Electron disagree on an ABI you are recompiling native modules and praying. EkkoJS ships its own native layer and packs the right binary straight into your .ekl, so the desktop app carries the native code it needs and runs. No rebuild step, no version roulette between two runtimes that were never quite in sync.
Testing is part of the deal
If I want everything I need out of the box, then testing has to be in the box too. Fully. So EkkoJS ships a complete test framework and a coverage reporter inside the runtime, with nothing to install and nothing to configure.
The API is the one you already know. describe, test, expect, plus mocks when you need them.
// math.test.ts
import { describe, test, expect } from "ekko:test";
describe("addition", () => {
test("adds two numbers", () => {
expect(1 + 1).toBe(2);
});
// async tests are awaited for you
test("resolves a value", async () => {
expect(await Promise.resolve(42)).toBe(42);
});
});
Name your files *.test.ts and run them. The runner discovers them on its own, needs no permissions, and exits non-zero when something fails, so it drops straight into CI without ceremony.
ekko test # discover and run every *.test.ts
ekko test math.test.ts # run a single file
ekko test --coverage # collect a coverage report while you are at it
Coverage is built in the same way. One flag produces an lcov report and an HTML view you can open in a browser, for both JavaScript and TypeScript, with no extra tooling to bolt on.
Security first, deny by default
A program in EkkoJS starts with nothing. No file system, no network, no environment, no process spawning, no crypto, no FFI. The first time the code reaches for a native capability it was not granted, it gets a PermissionError and stops.
You decide what it can touch, with one flag.
ekko run app.ts # no grants, native calls throw
ekko run --allow=fs,net,crypto app.ts # grant whole categories
ekko run --allow=all app.ts # grant everything, for when you are just hacking
While you are building, --allow=all keeps you out of your own way. When you ship, you grant exactly what the code needs, and you can scope each grant down to a path, a host or a single variable.
ekko run --allow=fs:./data/** app.ts # the file system, but only under ./data
ekko run --allow=net:api.example.com app.ts # the network, but only to one host
ekko run --allow=env:DATABASE_URL app.ts # one environment variable, nothing else
The path checks resolve symlinks before they decide, so a link inside an allowed folder that points somewhere it should not goes nowhere. A ./data/link aimed at /etc/passwd is denied even with fs:./data/**.
You can also write the grants once in ekko.json so you never retype a flag, and the CLI still wins when you need to override:
{
"permissions": {
"fs": true,
"net": true,
"env": ["DATABASE_URL", "API_KEY"]
}
}
The doctrine behind it is simple. Developer tools like ekko test and ekko dev run with full trust, because that is your machine and your code. Shipped code, run with ekko run, gets the smallest set of capabilities that lets it do its job. The runtime makes the safe path the easy one, so locking things down is the default rather than the heroic afterthought.
Rune, the full stack framework
We have all lived this one. You build a slick client-side app, everything is fast and smooth, and then someone asks the obvious question: how does Google see it? So you bolt on server-side rendering, and suddenly every navigation is a round trip to the server, and the snappy app you were proud of feels like it is wading through mud. You traded performance for SEO, and nobody is happy.
Or the other classic. The app is gorgeous and client-side first, and then a user presses F5 halfway through a flow and the whole thing forgets who it is. Empty screen, lost context, back to the start. One tap of the refresh key and your app has amnesia.
Rune is my answer to both. You can see it at rune.ekkojs.com.
The philosophy fits in one line: server-rendered on the first load, fully client side after. The first paint comes from the server, so any URL you land on is real and indexable, which keeps SEO happy. From there you are a single-page app, navigation stays on the client, and nothing does a useless round trip just to redraw a page. You get the SEO without paying for it in performance.
A page is a React component, plus an optional ssr() that runs on the server for that first load and feeds the metadata.
// pages/index.tsx
// ssr() runs on the server, on first load only, and sets the page metadata,
// so the URL is fully indexable before any JavaScript runs.
export function ssr() {
return { title: "Home, EkkoJS", description: "Server-rendered, then client side." };
}
// After that first paint, this is a normal React app, navigation stays on the client.
export default function Home() {
return <h1>Hello from Rune</h1>;
}
And the refresh problem? Rune ships Mimir, a state library that lives across the frontier. The same state flows out of the server render, into the client, and keeps going there, surviving a refresh. So F5 stops being the button that wipes your app's memory.
That is the whole idea. SEO without sacrificing performance, and a client-side app that does not panic the moment someone reaches for refresh.
Asgard, the component suite
A framework is more pleasant when you are not drawing every button from scratch, so EkkoJS has an official component suite, Asgard.
It is a batteries-included React component library made for Rune apps. Buttons, text fields, selects and switches, date and color pickers, data tables, tree views, alerts, toasts, tooltips and drawers, a real layout engine, and an IDE-grade docking system for the kind of app that has panels you drag and split. The components render on the server with Rune and hydrate cleanly on the client, so they fit the SSR-first model without a fight.
You add it like any other package and wrap your app in a theme.
import { ThemeProvider, Button } from "@ekko/asgard";
import { themes } from "@ekko/asgard/theme";
export default function App() {
return (
<ThemeProvider theme={themes.nord}>
<Button variant="filled">Get started</Button>
</ThemeProvider>
);
}
Theming runs deep. There are 27 built-in palettes, Nord, Dracula, Tokyo Night, Catppuccin and more, or you bring your own, and a bridge that re-themes your own SCSS the moment you switch. Keyboard navigation, focus management and ARIA roles come built in, so accessibility is the starting point rather than a later cleanup.
Bifrost, the registry
A package format is only half the story. The other half is somewhere to send it, so EkkoJS has its own registry, Bifrost.
The flow is the one you would expect. You pack your project into a single .ekl, you log in once, and you publish.
ekko login # stores a token in ~/.ekko/config.json
ekko publish --dry-run # see exactly what would be uploaded, no surprises
ekko publish # pack and push to Bifrost
On the other side, installing it is the same one command as any other dependency, and it lands in the shared store rather than in a folder inside the project.
ekko add @me/app
Every package is versioned, and if you have a signing key it is signed with ECDSA on the way up, so what you publish is what people install, provably. The same crypto runs in the CLI and in the registry, one implementation on both ends, so there is no seam where a signature means one thing here and another thing there.
Visibility is yours to set in ekko.json, public or private, and the registry checks quota and validity before a single byte is uploaded. You always know what is going out before it goes.
Zero-shot runtime coding
I can already hear the objection. "A brand new runtime in 2026? My AI assistant has never heard of it. I will be flying blind, autocompleting into the void, fighting a model that keeps trying to write me a package.json."
It is a fair fear. It is also the first thing I solved.
The entire documentation ships inside the binary. ekko doc browses it right in your terminal, across three topics: the runtime, the Asgard components, and the Rune framework. You list a topic's pages, then open any one of them.
ekko doc js # list the runtime's pages, each one numbered
ekko doc js 007 # open a page (here, Permissions)
ekko doc js 007 --llm # the same page as raw markdown, written for a machine
That last flag is the one that matters for tooling. --llm prints the page as clean markdown, so an AI assistant can list the pages and pull whichever ones it needs, on demand, as plain text.
Drop the flag and you get the same page rendered for a human, formatted right there in the terminal, with headings, tables and syntax-highlighted code. And if you would rather read and explore than type page numbers, -i opens a full interactive browser, a split-pane TUI you navigate with the keyboard.
ekko doc js 007 # human-friendly, rendered in the terminal
ekko doc js -i # full interactive TUI, browse the whole topic
No fine-tuning, no prior training, no waiting for the next model to maybe learn about EkkoJS. Any capable AI can read the live, exact documentation for the version you are actually running, and code against it from a standing start. Point your assistant at ekko doc and it learns the runtime as well as the docs do, today, not whenever a training cut-off catches up.
So the runtime being new stops being a problem and starts being the point. It documents itself, for you and for the machine sitting next to you, and the guidance is always current because it comes from the same binary that runs your code.
Where it stands
Let me be honest about the stage we are at. EkkoJS is a technology preview until October. Breaking changes are already on the roadmap, on purpose, because I would rather get the design right in the open than freeze the wrong decisions early.
So here is my ask. Test it. Build something real with it. Push on it and tell me where it hurts. But please, do not ship it to production yet. That day is coming, and this is not it.
Stay tuned. A lot of articles and tutorials are on the way, and I will be walking through every part of this in more depth. This was the overview. The rest is coming.
Try it
Installing is a single command, and that command is the whole toolchain. It runs your code, manages your packages, runs your tests, and builds your projects.
# macOS / Linux
curl -fsSL https://ekkojs.com/install.sh | sh
# Windows (PowerShell)
irm https://ekkojs.com/install.ps1 | iex
Then write your first line.
// main.ts
console.log("Hello, EkkoJS!");
ekko run main.ts
That is it. No tsconfig, no bundler, no folder full of dependencies. If you have read this far, the best thing you can do is install it, build something small, and tell me what you think. EkkoJS is born from the Norwegian word for echo, so write once, and let it resonate.
Enjoy using it.
Francois, creator of EkkoJS.







Top comments (0)