Status: future · Priority: medium · Depends on: JS Coverage
Note: This is a future plan, not a commitment. The syntax and API shown here are proposals — they can be completely different when actually implemented.
The idea in one line: anything that runs on Node.js should run on Morph — import ... from "morph/fs", npm install anything, even entire servers — compiled to native C++, with no Node process shipped. The node:* spelling (node:fs, node:path, ...) keeps working as an alias, but in .mx files morph/* is the preferred style.
The everyday JS surface already compiles well — variables, functions, classes, arrays, strings, fetch, timers, promises, async/await all lower to native code today (see JS Coverage for the running catalog). That baseline working so well raised an obvious question: if UI logic compiles cleanly, why should server-style code be any different?
Reading through the Node.js docs, the answer started looking encouraging rather than crazy. Node's built-in surface (node:fs, node:path, node:http, ...) is a finite, documented API list — and each entry maps to a C++ equivalent the same way fetch and setTimeout already do. So this stopped looking impossible and started looking like a (large, honest) list of modules to implement, one at a time, behind the morpher we already have.
Fair question — Bun is fast and modern. Three reasons we target Node's surface, not Bun's:
node:* is the standard. Implement Node's surface and Bun-oriented code largely comes along for free — the reverse wouldn't be true.One clarification, because "not Bun" is easy to misread as "slower": it's the opposite. We're borrowing Node's API surface, not its engine. Bun is fast for a runtime that still runs JavaScript; Morph runs no JavaScript at all at runtime — no interpreter, no JIT, no VM. Your code is compiled ahead of time to optimized native C++ (with Rust where it wins), so what executes is machine code with the JS as surface only. The expectation is multiples of Bun's performance, not parity with it.
In .mx files you import from morph/* — the preferred Morph style:
import { readFile } from "morph/fs";
import path from "morph/path";
const text = await readFile(path.join("data", "notes.txt"), "utf8");Same APIs as Node, no wrapper to learn — if you know Node, you already know this surface. And the Node spelling keeps working as an alias:
import { readFile } from "node:fs/promises"; // same module as morph/fsnode:* exists so Node code pastes in unchanged — and because npm packages import node:* internally, morpher has to resolve both spellings regardless. Prefer morph/* in code you write; node:* is the compat path.
npm install date-fnsimport { format } from "date-fns";
format(new Date(), "'Today is a' eeee");Packages resolve the normal way and get compiled at build time through morpher — the Package Build Bridge mechanism, pointed at the npm registry instead of a Morph-only registry. Pure-JS packages just work; packages with native addons (node-gyp, prebuilt .node binaries) don't — the same boundary every non-Node runtime draws.
import http from "morph/http"; // node:http works too
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end("hello from native code");
});
server.listen(3000);morph build compiles it to a native binary — no V8, no Node process, no bundled runtime. Your server logic becomes machine code with the same coroutine scheduler your UI already runs on.
fetch lowers to coroutine HTTP, setTimeout lowers to the scheduler. morph/fs lowers to std::filesystem, morph/path to small pure functions, morph/http to a socket layer — and node:fs, node:path, node:http resolve to those same C++ modules as aliases. Morpher grows a module table (canonical morph/* entry plus generated node:* aliases); nothing about the architecture changes.morph::Result<T>, co_await). A server is that same scheduler with sockets attached and a loop that doesn't exit — new I/O surface, not a new execution model.node:worker_threads semantics that assume V8 isolates, and anything that needs an actual JS engine at runtime. The line is "compilable to C++", and morph check already enforces lines like that (mx-js-* diagnostics).One rule, stated upfront: morpher implements what the Node.js documentation says, nothing more. Hyrum's Law observes that with enough users, every observable behavior of a system gets depended on — error message strings, timing quirks, undocumented edge cases. We are explicitly not signing up for that.
The reason is architectural: we are not porting Node, we are rewriting each module natively for maximum performance. morph/fs is std::filesystem behind a Node-shaped API, not libuv with its exact scheduling quirks; morph/http is a socket layer, not a byte-for-byte port of Node's parser. Same documented inputs and outputs, different insides — so anything undocumented (exact error texts, timing, internal ordering) will differ, and that is by design, not a bug.
What this means in practice:
| Piece | State |
|---|---|
| Everyday JS → C++ (the foundation) | ✅ Shipped and expanding (JS Coverage) |
Async core (fetch, timers, promises → coroutines) |
✅ Shipped |
morph/* + node:* module surface |
❌ Not started |
| npm resolution at build time | ❌ Not started (the bridge itself is unbuilt — see Packages) |
| Server-style loop semantics (sockets, listen/accept, long-lived loop) | ⚠️ Scheduler exists; server I/O unproven |
morph/path (pure functions, no I/O, with its node:path alias) is the obvious beachhead — it proves the import path end to end. Then morph/fs, then process/process.env/argv (servers aren't real without env and args), then morph/http as the milestone that proves "entire servers".node:path/node:fs pay off inside UI apps (config files, local data) long before the first server ships.morph/path (+ node:path alias) — pure functions, proves the import path end to end through morphermorph/fs (sync + fs/promises shape) — file I/O over std::filesystemprocess / process.env / argv — the minimum for real CLI and server programsmorph/http server — the "entire servers in native C++" milestone