Turn a JavaScript or TypeScript program into one standalone executable that runs on every device, legacy Windows included, with no Node.js installed on the device.
- What Graak is
- Installation
- Quick start
- How a build works
- Supported targets
- Programs Graak runs
- The Graak engine
- Package managers
- The Node.js engine
- CLI
- Programmatic API
- ForgeScript extension
- Environment variables
- Development
Graak packages a JavaScript or TypeScript project (a web server, a static website, a command line tool, a Discord bot, anything that runs on Node.js) into an executable for a chosen target device. It has two engines:
- The Graak engine (default for legacy Windows, iSH, 32-bit Linux and 64-bit Linux): its own C host,
graak-c, embedding quickjs-ng and a Node.js-shaped standard library. No Node.js is shipped or needed on the device. The host is about 4.5 MB (it carries TLS, WebAssembly, SQLite and libffi) and imports only what Windows XP already has, so the result runs where Node.js itself cannot. - Node.js: a downloaded, SHA-256 verified Node.js runtime, packaged as a Single Executable Application or a
portable folder. Used for the targets Node.js serves well, and available everywhere as
--engine node.
Nothing in Graak is specific to any framework. Express, Fastify, Hono, ws, React and Vue builds, discord.js and
ForgeScript bots all go through the same path; the ForgeScript extension is an optional
adapter that exposes a few of Graak's helpers as $functions.
Graak works beside Bun and Deno rather than against them. Both have a built-in compile command that makes a
binary for a modern 64-bit desktop, and both need their own runtime on the device's CPU and OS. Graak takes what they
cannot: Windows XP, Vista and 7, 32-bit Windows and Linux, iSH, ARMv7 and FreeBSD, and a single file of about 5 MB with no Bun
or Deno on the device. Bun and Deno stay the place a project is written and resolved; Graak reads what they resolved
and builds it. See Package managers.
npm i -g graak # or: npx graak <command>, or add it to a project with npm i -D graakGraak needs Node.js >= 20.12 on the machine that builds. The compiled program needs nothing on the device when it runs on the Graak engine.
Ready-made hosts for every target ship with the package, so a build needs no compiler, no git and no network on any
operating system, Windows included. Only a change to the host's own C sources (see Development) needs
sh, cmake, git, mingw-w64 for Windows targets, and the x86_64-linux-musl-cross / i686-linux-musl-cross
toolchains from musl.cc for Linux ones. Building for a Node.js target downloads the runtime unless
--offline or --node-binary is given.
graak compile dist/index.js --target win-legacy-x64 # a program, for Windows 7 (64-bit)
graak compile src/index.ts --target linux-modern-x64 # TypeScript straight from source
graak compile ./dist --target win-legacy-x64 --spa --port 8080 # a built React/Vue/HTML website
graak compile dist/index.js --target win-xp-x86 --engine native --strategy sea --output app.exe # one file- Point
compileat the program's entry file, or at a folder of built static files. - Copy the output to the device and run the launcher inside it (
<name>.cmdon Windows,<name>elsewhere), or the single executable itself. A Graak-engine build is ready to run as it is; a Node.js build extracts its archive beside the executable on first start. graak targetslists every device andgraak info <target>explains one.
- Collect. The input is a program (the directory of the closest
package.jsonwith its productionnode_modules) or a folder of built static files. pnpm and Bun symlink layouts are flattened into a plain, resolvable tree, and a Yarn Plug'n'Play project is materialized into one..env,.npmrc,.gitand devDependencies stay out unless asked for. - Convert. For the Graak engine, ES modules (
.mjs,"type": "module", ES-module-only packages), TypeScript and JSX become CommonJS with esbuild. Converted files are cached on disk, so a rebuild skips them. - Adapt. Native addons are checked, V8/NAN addons are rebuilt from source for the target, and for Windows Vista and 7 the imports those systems lack are redirected to Graak's compatibility DLLs.
- Trim. For the Graak engine, only the files the program can load ship. Its own files always do; from them the
module graph is followed file by file with the host's own resolution rules (
exportswithrequirefirst,imports,main, index files), and packages nothing reaches are left out. Type declarations, docs, source maps (unlesssource-map-supportis loaded), tests, examples, addon build inputs (C sources, headers,binding.gyp) and binaries built for another platform go too. Nothing is bundled or minified, so__dirname, stack traces and module identity stay as they were. Where loading is decided at run time, the package ships whole instead: code that requires a computed name (a command loader,require(variable),createRequire) keeps its package and every package it depends on; code that reads files by path (__dirname,import.meta.url), arequire.resolve()target, or a package with a native addon keeps that package. A ForgeScript bot goes from 8,650 files (23.8 MB) to 2,472 (8.6 MB); an Express, ws and better-sqlite3 server from 36.7 MB to 5.8 MB on Windows 7. The log says what was kept;--no-trimships every collected file. - Package. Output is one of three strategies:
- native: the Graak host
graak-c[.exe],runtime/(the Node.js-shaped standard library),app/(your program and itsnode_modules, as loose files) and a launcher. Files the program writes toapp/, SQLite databases included, stay put across rebuilds. With--engine native --strategy seait is one file. - sea: a Node.js Single Executable Application injected into a target Node.js runtime (>= 20.12), extracting its archive beside itself on first start.
- portable: a folder with the archive,
boot.cjs, a launcher and the runtime when one is available.
- native: the Graak host
--engine node,--node-binary,--strategy sea|portable(without--engine native) or a runtime registered withgraak runtimes addmove a Graak-engine target onto Node.js. Everything else stays on the Graak engine.
A build that takes long says where: any stage of 750 ms or more prints how long it took.
| Target | Platform | Engine |
|---|---|---|
win-xp-x86 |
Windows XP / Server 2003 (NT 5.1/5.2) | Graak engine |
win-vista-x86 |
Windows Vista (32-bit) | Graak engine |
win-vista-x64 |
Windows Vista (64-bit) | Graak engine |
win-legacy-x86 |
Windows 7 (32-bit) | Graak engine |
win-legacy-x64 |
Windows 7 (64-bit) | Graak engine |
ios-ish-x86 |
Alpine (musl i686) under iOS iSH | Graak engine |
linux-x86 |
Linux 32-bit (i686) | Graak engine |
linux-modern-x64 |
Linux 64-bit (x86_64) | Graak engine, static musl or --native-libc glibc |
freebsd-x86 |
FreeBSD 32-bit | portable Node.js, installs itself on-device (pkg) |
win-x86 |
Windows 10 / 11 (32-bit) | sea, official Node.js (up to Node 22) |
linux-armv7 |
Linux ARMv7 (32-bit) | sea, official Node.js |
win-modern-x64 |
Windows 10 / 11 (64-bit) | sea, official Node.js |
linux-modern-arm64 |
Linux ARM64 (AArch64) | sea, official Node.js |
darwin-x64 |
macOS Intel | sea, official Node.js (sign with codesign --sign -) |
darwin-arm64 |
macOS Apple Silicon | sea, official Node.js (sign with codesign --sign -) |
The Graak engine is the default wherever Node.js itself is the obstacle: Windows 7 tops out at Node 12, Vista at
Node 5, XP has no Node.js at all, and 32-bit Linux's last build is an unofficial Node 12. Any target can still be moved
onto Node.js with --engine node, --node-binary, --strategy sea|portable or a registered runtime.
Every package manager and runtime (npm, pnpm, Yarn, Bun, Deno) may build every target. deno compile itself makes
only five of them (linux-modern-x64, linux-modern-arm64, win-modern-x64, darwin-x64, darwin-arm64) and
bun build --compile a similar set; every other target above is Graak's.
The Graak engine runs any JavaScript program that stays inside the Node.js API it provides, including programs that listen: web servers, APIs, dashboards. What makes that possible is a real network stack in the host (non-blocking sockets served from one poller, so a program can be its own client, and TLS in both directions on mbedTLS) with JavaScript implementations of the Node.js modules on top of it.
The bar for those modules is not "works" but "prints exactly what Node.js prints": the test suite runs the same program on Node.js and on the packaged host and compares the output byte for byte.
| Module | What is covered |
|---|---|
http, https |
Server and client. HTTP/1.1 keep-alive, chunked bodies both ways, pipelined requests, Expect: 100-continue, HEAD/204/304 framing, Upgrade (WebSocket servers such as ws), backpressure and drain, closeAllConnections. HTTPS servers take a PEM key and cert. |
net, tls |
createServer, connect, half-close, timeouts, pause/resume, remoteAddress and friends. TLS takes ca, cert/key (with passphrase), servername, ALPNProtocols, minVersion/maxVersion (TLS 1.2 and 1.3), requestCert, rejectUnauthorized, checkServerIdentity and secureContext, and sockets report alpnProtocol, authorized, authorizationError, getProtocol(), getCipher(), getPeerCertificate() and getPeerX509Certificate(). tls.rootCertificates is the CA bundle compiled into the host. rejectUnauthorized: false and NODE_TLS_REJECT_UNAUTHORIZED=0 work as in Node. Not covered: pfx, ciphers (the suite list is mbedTLS's), session resumption and renegotiation. A server built on mbedTLS answers a TLS version mismatch with a handshake_failure alert where OpenSSL sends protocol_version. |
http2 |
Server (createServer, createSecureServer with ALPN, allowHTTP1 falling back to http) and client (connect) over cleartext prior-knowledge h2c and TLS: HPACK with Huffman coding, multiplexed streams with flow control both ways, settings, ping, goaway, RST_STREAM, trailers, server push, informational responses, respondWithFile, the core stream API and the compatibility request/response API, Node's error codes and messages. Verified against Node.js, curl and nghttp2 in both directions. Not covered: the HTTP/1.1 Upgrade to h2c (Node has no server side for it either), stream priority (accepted and ignored) and origin frames. |
fetch, Request, Response, Headers, FormData |
Streaming bodies, redirects (follow, manual, error), gzip/deflate, AbortSignal, clone(), multipart, Response.json() and Response.redirect(). |
stream |
Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, Readable.from, async iteration, stream/promises, and the web-stream bridges. |
fs, fs/promises |
Sync, callback and promise forms, file descriptors, Stats/Dirent, streams, cp, rm, mkdtemp, recursive readdir, and Node's error codes and messages. |
crypto |
Hash and HMAC (md5, sha1, sha224/256/384/512, ripemd160), random values, PBKDF2, HKDF, scrypt, AES (ECB, CBC, CTR, GCM), ChaCha20-Poly1305, RSA (PKCS#1 v1.5, PSS) and ECDSA (DER or IEEE P1363) sign/verify, publicEncrypt/privateDecrypt with PKCS#1 v1.5 and OAEP, key objects (createPrivateKey, createPublicKey, PEM, DER and JWK in and out, equals, asymmetricKeyDetails), generateKeyPair and generateKeySync for RSA, EC, Ed25519, Ed448, X25519, X448 and DSA, the post-quantum key types in pure JavaScript over the host's SHAKE and SHA-2 (ML-KEM-512/768/1024 with crypto.encapsulate/decapsulate, ML-DSA-44/65/87 and the twelve SLH-DSA sets with sign/verify and the context option, keys in PEM, DER, JWK and raw-public/raw-seed/raw-private), pfx and passphrase-protected key export, streaming ciphers, AES key wrap, SHA-3/SHAKE, createECDH and crypto.diffieHellman, createDiffieHellman and the MODP groups, generatePrime and checkPrime (including the add/rem congruence and safe prime constraints), X509Certificate, timingSafeEqual, and Web Crypto (subtle) for digest, HMAC, AES, RSA, ECDSA, ECDH, EdDSA, ML-KEM (encapsulateBits, encapsulateKey, decapsulateBits, decapsulateKey), ML-DSA, key wrap and key derivation, and SubtleCrypto.supports, on the same key objects. Output is byte-identical to Node's. Zstd is in zlib; URL follows the WHATWG URL Standard with full IDNA and URLPattern is global, both checked against Node 24 and 26. |
Buffer, events, zlib, string_decoder |
Complete Buffer, an EventEmitter that tolerates being mixed into plain objects, gzip/deflate/raw and Brotli (brotliCompress, brotliDecompress, params, maxOutputLength, Node's error codes) in sync, callback and stream forms. |
os, process, vm, module, url, worker_threads, readline, punycode |
Provided. process has real standard streams, exitCode, beforeExit/exit and signals; vm contexts are sandbox objects in one realm, not a security boundary. |
child_process |
spawnSync, execSync, execFileSync, exec and execFile (each runs the child to completion), with byte-exact output, input, ENOENT for a missing program, and signal for a killed one. spawn is live: stdout and stderr arrive as 'data' while the child runs, stdin can be written and ended at any time, stdio takes pipe, inherit, ignore or a descriptor, kill() sends real signals (on Windows every signal terminates, and is reported as the one asked for, as libuv does), and 'exit' precedes 'close'. fork has an IPC channel with JSON messages, process.send, 'message' and 'disconnect', execArgv, silent and stdio, and forking the packaged program itself works in a single-file build. Known gaps: serialization: 'advanced' and sending handles over the channel throw a clear error; stdio entries beyond the three standard ones (other than ipc) are not supported; a child that is unref()ed is no longer polled, so its events stop; on Windows before 10 the channel is a pair of anonymous pipes, so it links Graak programs to each other but not to a Node.js parent or child. |
dgram, net over a path |
UDP sockets (createSocket('udp4'/'udp6'), bind, send, message, broadcast, TTL, multicast membership) and unix-domain sockets (listen(path), connect(path)). Windows before 10 has no AF_UNIX, so there a socket path is a file that names a loopback TCP port: programs built with Graak reach each other that way, but a program that is not one of them cannot connect. |
dns, dns/promises |
Resolver, getServers, setServers, lookup, lookupService, and resolve, resolve4, resolve6, resolveCname, resolveMx, resolveNs, resolveTxt, resolveSrv, resolvePtr, resolveSoa, resolveCaa, resolveNaptr, resolveTlsa, resolveAny, reverse over UDP with TCP fallback, with Node's record shapes, ttl, error codes and messages, argument checks, retries and server rotation (timeout, tries, maxTimeout), cancel() and setLocalAddress(). lookup reads the hosts file, then the system servers. System resolver discovery: /etc/resolv.conf (with timeout/attempts) on POSIX; on Windows XP and later the output of ipconfig /all in any display language, then nslookup. |
node:sqlite, bun:sqlite |
SQLite compiled into the host: DatabaseSync and StatementSync as Node documents them (null-prototype rows, named parameters with or without their prefix, readBigInts, changes and lastInsertRowid, error codes) and Bun's Database, query, prepare, transaction. Same database file format on every target. node:sqlite's standalone backup() function is implemented (rate-limited stepping, progress, source/target for attached databases), matching Node's argument and option validation, its error shapes, and (from Node 24) that options.rate must be positive and the destination takes the same string/Uint8Array/file: URL forms as DatabaseSync's own path. DatabaseSync.prototype.function() binds a scalar SQL function through sqlite3_create_function_v2() (its argument conversion, deterministic/directOnly/varargs/useBigIntArguments options, return-value coercion and error propagation all match Node's). Loading SQLite extensions (enableLoadExtension/loadExtension) is not available: the bundled amalgamation is compiled with extension loading disabled, and a static host has no dlopen to load one with anyway -- the same limit as native addon loading on that host. |
WebSocket, MessageEvent, CloseEvent |
The browser's client on RFC 6455 framing over the host's own HTTP upgrade (Node 22 has one; the host and older Node.js did not). Messages, binaryType, ping/pong, close codes and the events arrive as they do in Node's. |
fs.watch |
A file or a directory, recursive or not: an appearing or disappearing name is a rename, a changed one a change. The engine has no change notifications, so the tree is compared with a snapshot at an interval that grows with its size. |
WebAssembly |
wasm3, compiled into the host, behind the standard API (Module, Instance, Memory, Global, Table, instantiate, compile, validate, i64 as BigInt). It interprets, so there is no SIMD, threads or exception handling, and a module that imports a memory, table or global is refused with a LinkError. This is what lets undici (Node's fetch and the HTTP client of discord.js) load. |
assert |
Complete: the loose and strict families, deep equality by Node's rules (Map, Set, Date, RegExp, typed arrays, errors, boxed primitives, circular references, prototypes), throws/rejects with every form of expected, match, ifError, partialDeepStrictEqual, and the message and AssertionError fields Node builds. |
| Web streams | web-streams-polyfill 3.3.3, which follows the WHATWG spec and passes its web-platform-tests: backpressure, byte streams and BYOB readers, pipeTo/pipeThrough, tee, ReadableStream.from, and TextEncoderStream, TextDecoderStream, CompressionStream, DecompressionStream. |
structuredClone, MessageChannel, BroadcastChannel, events, timers, util, path.matchesGlob, fs.glob |
The statics and members programs and packages reach for, each checked against Node's own output. structuredClone handles cycles, Map/Set, Date, RegExp, errors, typed arrays and transfer. |
Intl |
NumberFormat (decimal, percent, currency, unit; compact, scientific and engineering notation; every rounding mode, increment and priority), DateTimeFormat (every component combination, dateStyle/timeStyle, hour cycles, day periods, eras, zone names, formatRange), PluralRules, RelativeTimeFormat, ListFormat, Collator (Unicode collation with locale tailoring: Swedish å ä ö, Spanish ñ, Turkish dotted i, Russian and Chinese script order, pinyin Han), DisplayNames, DurationFormat, Locale, and toLocaleString, toLocaleDateString, toLocaleTimeString, localeCompare and toLocale{Upper,Lower}Case on the built-ins. 39 locales (see below) with every IANA time zone and its history since 1700. Output is compared with Node's ICU across some 30,000 cases. |
Intl.Segmenter |
Grapheme and word granularity per UAX #29, passing Unicode's own conformance files in full. Sentence granularity throws rather than guessing a locale. |
node:test, node:test/reporters |
Reachable only under the node: scheme, as in Node. test/it, describe/suite, the four hooks, subtests, skip/todo/only/expectFailure, t.plan, t.assert, t.diagnostic, t.signal, t.waitFor, timeouts, callback tests, mock (fn, method, getter, setter, property, timers, module), run(), the default report and exit code, and the spec, TAP, dot, JUnit and lcov reporters, checked against Node.js 24 and 26. A test after an await must be started with t.test() (the engine has no async context to find its parent by); run() runs files in-process (isolation: 'none'); coverage, snapshots and watch mode throw rather than pretend. mock.module exists only under --experimental-test-module-mocks, exactly as in Node (checked against Node.js 24.21.0 and 26.9.0 — the flag gates CommonJS require() mocking too, not only ESM); once enabled it covers both require() and import/import(), including builtins, with cache, exports/namedExports/defaultExport, and restore/reset — the quickjs engine's own native module loader now calls back into JS for a mock the same way it loads a real file. Two narrow ESM-only gaps, both from quickjs caching a loaded module by specifier at the engine level before the loader is ever consulted, and neither reachable from outside the engine's own C source to undo: cache: false behaves like cache: true (a second import() of the same specifier can't be handed a fresh module instance), and mocking a specifier some earlier import/import() already resolved has no effect (real Node's loader can retroactively swap it; this one can't). Mocking before the first import of a specifier — the normal pattern — is unaffected by either. |
wasi |
WASI (preview1 and unstable) with getImportObject, start, initialize, finalizeBindings, preopens, args, env and returnOnExit, over every wasi_snapshot_preview1 call, in JavaScript on the runtime's fs. Sockets return the codes Node returns. Known gaps: poll_oneoff treats pipes and terminals as always ready. |
cluster |
Primary and workers on fork(): fork, setupPrimary, workers, Worker and their events, disconnect, and workers that listen on one port (the primary owns the socket and hands connections to workers round-robin). Limits are listed below the table. |
v8 |
serialize, deserialize, Serializer, Deserializer and the Default* classes in V8's wire format (version 15), byte-identical to Node's for the values structured clone accepts. A Proxy always throws, like V8, with a message that matches Node's for the shapes V8 special-cases (plain object, array, Map, Set, Date, RegExp, Error, a revoked proxy). Known gaps: V8's packed-elements kind is invisible to JavaScript, so new Array(3).fill(0) is written densely; a Proxy wrapping a callable also always throws, but its message uses this engine's own Function.prototype.toString output rather than V8's peek at the real target's source, so the text does not reliably match Node's. |
repl |
repl.start() and REPLServer over any pair of streams, or the process's own: _ and _error, .help .break .clear .exit .save .load (and .editor on a terminal) and defineCommand, multi-line input, top-level await, let/const/class that persist with a real script scope's redeclaration and const-assignment errors, context, useGlobal, writer, eval, replMode, ignoreUndefined, completer and Node's own tab completion (members, globals, require('...'), commands), history with setupHistory, and the exit and reset events; repl.builtinModules, REPL_MODE_*, Recoverable, isValidSyntax. It is checked against Node 24 and 26 transcript for transcript. A terminal gets a plain line editor (arrows, history, Tab, Ctrl+A/E/K/U/W/L/C/D); Ctrl+C is caught as a signal while it runs. Known gaps: a context is a sandbox object in the one realm (Object inside it is the program's Object), syntax errors carry the engine's wording and no source echo with a caret, Uncaught errors show no frames of functions defined in the session, var next to another statement ends the input's value as undefined (V8 keeps the earlier statement's), a typeof on a missing name is rewritten to a guarded read, and a terminal has no inline result preview, reverse search or multi-line editing. breakEvalOnSigint cannot interrupt a running script. |
inspector, inspector/promises |
Session (connect, post, disconnect, inspectorNotification and per-method events) answering the parts of the Runtime domain a program can use from inside itself with V8's shapes: evaluate (returnByValue, awaitPromise, generatePreview, objectGroup), getProperties, callFunctionOn, releaseObject(Group), enable with executionContextCreated and consoleAPICalled, globalLexicalScopeNames and the small ones, plus url() (undefined), console, close() and waitForDebugger() (Inspector is not active). quickjs has no debugger protocol, sampling profiler or heap profiler: Profiler, HeapProfiler, Debugger and the other domains that need them, a few Runtime methods (compileScript, runScript, ...) and inspector.open() throw an ERR_INSPECTOR_NOT_AVAILABLE error that says so. Error descriptions carry this engine's stack. |
quickjs-ng has no ICU, so Intl here is built from data read out of one: tools/gen-intl-data.js formats sample values with
Node's ICU and stores what it printed (patterns, names, weights, zone transitions), and quickjs/runtime/intl.js assembles
them. The locales are en (US, GB, AU, CA, IN, NZ, IE, ZA, SG), de (DE, AT, CH), fr (FR, CA, BE, CH), es (ES, MX, AR, CO,
CL, US, 419), it (IT, CH), pt (BR, PT), nl (NL, BE), sv (SE, FI), pl, ru, tr, ja, zh (CN, TW, HK) and ko;
another region of one of those languages uses the nearest of them and reports the locale that was asked for. A language
outside the list formats as en-US, which is also what supportedLocalesOf leaves out. Data is loaded a locale at a time, on
first use, and a build ships it only when the program or a package it bundles mentions Intl, toLocale*String or
localeCompare (--intl all or --intl none overrides; about 7 MB, 1.5 MB compressed), so a program that never touches
Intl pays nothing for it. Not covered: calendars other than Gregorian, numbering systems other than Latin, ICU's interval
patterns (formatRange joins two full dates with the locale's range separator instead of merging the shared fields), unit
compositions beyond X-per-Y, and ICU's generic zone names in a few zones (Egypt's disambiguating "(Egypt)"). The generator's
ICU is Node 26's (Current), which is newer than Node 24.21.0's (LTS): Intl.PluralRules.resolvedOptions()'s notation
field, Intl.Collator's default collation for zh ("pinyin"), the wording of a malformed-tag RangeError, and
crypto.getCipherInfo()'s key order all moved between the two, and the host follows 26 throughout — verify any of these
against both baselines before assuming a 24-only mismatch is a bug here.
Verified against real packages, with no Node.js anywhere in the output: Express 4, Fastify and Hono, each
served from a packaged linux-modern-x64 build; ws as a WebSocket server and client exchanging text and 70 KB
binary frames; discord.js 14 with ForgeScript, whose REST client makes a real request to Discord; and native
addons such as @napi-rs/canvas, sharp and better-sqlite3.
Limits of the crypto additions: AES-OCB (createCipheriv), cSHAKE, TurboSHAKE, KT128/KT256 and KMAC (Web Crypto; KMAC keys import and export as JWK only, as in Node) run in JavaScript over the host's AES and Keccak. BLAKE2b512 and BLAKE2s256 (hash, HMAC, PBKDF2, HKDF) and Argon2 (crypto.argon2, argon2Sync, and Web Crypto Argon2d/i/id) are JavaScript around C compression functions the host provides (BLAKE2b, BLAKE2s, the Argon2 block function and Keccak-f), so the per-block work runs at native speed; a host built without them falls back to JavaScript for the same output, about ten times slower. createSign/createVerify and crypto.sign/crypto.verify with a BLAKE2 digest match Node for RSA and classic DSA keys:
both reject it with ERR_OSSL_DIGEST_NOT_ALLOWED (the streaming Verify.verify() returns false instead, as it does for
any other bad digest there). ECDSA is the one case Node itself does not restrict this way — it signs and verifies with a
BLAKE2 digest like any other, DER or IEEE P1363 — and so does the host: BLAKE2 is not in mbedTLS's own digest table, so
the digest of the signing input is computed in JavaScript (node-blake2.js) and handed to mbedTLS as already-hashed
bytes, which its ECDSA sign and verify need nothing more than to produce or check a valid signature. ML-KEM, ML-DSA and SLH-DSA (generateKeyPair, sign and verify, encapsulate/decapsulate, and Web Crypto ML-KEM and ML-DSA with SubtleCrypto.supports) are provided. On the native host the secret-key work is C: ML-KEM and ML-DSA are the PQ Code Package's mlkem-native and mldsa-native and SLH-DSA is its slhdsa-c, fetched at pinned commits by build.sh. They have no secret-dependent branches or table lookups, and the JavaScript layer only formats keys. ML-KEM and ML-DSA take microseconds to milliseconds and SLH-DSA signing takes well under a second for the f sets. A host built without them (a stale prebuilt) falls back to a JavaScript implementation with the same output, which is neither fast (SLH-DSA s signing takes 10 to 25 seconds) nor constant-time. Where Node.js 24 and 26 differ (PKCS#8 export of ML-KEM and ML-DSA keys, expanded-only keys, some error texts), the host follows 24: PKCS#8 carries the seed and the expanded key, and both forms, the seed alone and the expanded key alone, are read. Like Node, SLH-DSA is not in Web Crypto; signing is hedged (random), as OpenSSL's default. Importing an expanded-only ML-DSA key decodes and checks it in JavaScript, which is not constant-time, and an expanded-only ML-KEM key is checked for canonical encoding and its stored hash, not for the consistency of its two halves. cluster runs on fork(): workers, their events and messages, and several workers listening on one port.
The host cannot pass a socket descriptor, so the primary owns the listening socket and proxies each connection to a worker
in round-robin order (workers bind a private 127.0.0.1 port), sending the real remote address alongside over the existing
IPC channel so a proxied connection's socket.remoteAddress/remotePort match the client's, not the proxy hop's own;
sending a handle to a worker (worker.send(msg, handle)) throws ERR_FEATURE_UNAVAILABLE_ON_PLATFORM, and listen on a unix
path or with exclusive: true binds in the worker alone. An
exception nothing catches is handled as Node handles it: process.on("uncaughtException") gets it, otherwise it is
printed and the process exits with status 1. A server binds IPv4 unless told otherwise (listen(port) is 0.0.0.0).
The host loads CommonJS, so the build converts the rest. ES modules (chalk 5, nanoid 5, node-fetch 3, .mjs files, a
"type": "module" project), TypeScript (.ts, .mts, .cts) and JSX (.tsx, .jsx, automatic runtime) become
CommonJS while the project is collected; plain CommonJS is left alone without being parsed. import(),
import.meta.url and import.meta.dirname keep working, package.json exports (conditions in the order the package
lists them) and imports (#name) resolve as in Node, and an import written as ./x.ts finds the converted file. So a
TypeScript program compiles directly:
graak compile src/index.ts --target win-legacy-x64Not converted: top-level await (CommonJS cannot express it), tsconfig path aliases (a bundler's job) and decorators
that need experimentalDecorators. A TypeScript entry for a Node.js build must be compiled first.
Point graak compile at a folder of built files instead of a program:
graak compile ./dist --target win-legacy-x64 --spa --port 8080
graak compile ./build --target linux-modern-x64 --engine native --strategy seaA directory, or an .html file, is treated as a site (--static forces it). Graak generates a small web server and
packages it with the files; the result runs on every target that runs a program, legacy Windows included. It behaves
like a static host: MIME types, ETag and Last-Modified with 304 replies, byte ranges (video seeking), gzip for
text, an index file for directories with the redirect to the trailing-slash form, a custom 404.html,
Cache-Control: immutable for content-hashed file names, and no path that leaves the folder.
| Option | Effect |
|---|---|
--spa |
Answer unknown page routes with index.html, so client-side routing (React Router, Vue Router) works. A missing file such as /missing.png is still a 404. |
--port <n> |
Default port, 8080. At run time PORT in the environment or --port on the command line overrides it, and --port 0 picks a free one. |
--host <addr> |
Interface to listen on. Default 0.0.0.0. |
--index <file> |
Entry page. Default index.html. |
Server-rendered frameworks are programs, not sites: package their server entry file like any other program.
--engine native --strategy sea writes one executable instead of a folder. The application (the compatibility layer,
your program and its node_modules) is appended to a copy of the host in Brotli-compressed blocks of about 2 MB, and the
host runs it from inside itself: modules and every fs read of the program's own files are served from the payload,
decompressed on demand, under a directory <name>.graak that exists only there. Nothing is unpacked. Files that must be
real are extracted one at a time, beside the executable in <name>.graak (or in the temp directory when that folder is
read-only): a native addon and the shared libraries the payload carries, a program started with child_process, an
SQLite database or FFI library opened from the payload. The host is about 4.5 MB, so a small program or a static site is
a single file of about 5 MB; a discord.js bot with its dependencies is about 7.5 MB.
The payload is read-only, like the files of an installed program. Writing to one of its paths writes a real file there
(creating the directories it needs), and that process reads the real file from then on; the next start sees the
payload's copy again. A native addon that creates files beside itself without going through fs finds no such
directory unless something created it first.
graak compile dist/index.js --target win-legacy-x64 --engine native --strategy sea --output app.exequickjs/runtime/ is the part of Node.js's surface that can be written in JavaScript, on top of the engine's own
qjs:os and qjs:std primitives: node-compat.js (core, require with node_modules resolution, process, util,
events, path, os), node-buffer.js, node-stream.js, node-fs.js, node-http.js, node-fetch.js,
node-web.js (web streams, Blob), node-url.js, node-inspect.js (a console that prints like Node's),
node-system.js, node-wasm.js, node-crypto.js, and native-modules.js, which gives the host's native sockets, TLS,
crypto and zlib their Node.js shapes.
quickjs/runtime/selftest.js runs unmodified on both Node.js and the engine and is the check that matters: a layer
that merely loads proves nothing. tools/engine-conformance.js measures any engine against what real programs need:
| language | builtins | host APIs | node modules | total | |
|---|---|---|---|---|---|
| Node.js 26 | 10/10 | 10/10 | 8/8 | 30/30 | 58/58 |
| Node.js 12.22.12 (the Windows 7 pin) | 5/10 | 1/10 | 1/8 | 29/30 | 36/58 |
| quickjs-ng, bare engine | 10/10 | 10/10 | 0/8 | 0/30 | 20/58 |
| quickjs-ng + JavaScript layer | 10/10 | 10/10 | 8/8 | 21/30 | 49/58 |
| quickjs-ng + JavaScript layer + native host | 10/10 | 10/10 | 8/8 | 30/30 | 58/58 |
There is no gap in the 58 that is left as a stub.
quickjs/native/ is a single C binary, graak-c, that embeds quickjs-ng and supplies what needs native code, using
only Winsock 2 and CryptoAPI on Windows, both present since the 1990s:
- TCP and TLS (mbedTLS). Sockets stay on the native side and reach JavaScript as integer ids, so a JavaScript bug cannot produce a use-after-free. Certificates verify against a CA bundle compiled into the binary, not the OS store, which is what lets an old machine reach modern HTTPS hosts at all.
- Hashing, HMAC, secure randomness (mbedTLS), compression (miniz), WebAssembly (wasm3), SQLite (the amalgamation), foreign function calls (libffi), UDP and unix-domain sockets, timers, filesystem and process.
- Node-API for native addons (plus the part of libuv's ABI that addons such as rocksdb-native call), and single files that run in place (a Brotli decoder reads the payload).
One source tree serves every target; only the cross-compiler differs.
quickjs/native/build.sh win-xp-x86 # 32-bit Windows, XP-compatible
quickjs/native/build.sh win-x86 # 32-bit Windows, Vista and later
quickjs/native/build.sh win-x64 # 64-bit Windows, Vista and later
quickjs/native/build.sh linux-x86 # 32-bit Linux, static musl (also serves iSH)
quickjs/native/build.sh linux-x64 # 64-bit Linux, static musl (the linux-modern-x64 default)
quickjs/native/build.sh linux-x64-glibc # 64-bit Linux, dynamic glibc (--native-libc glibc)
quickjs/native/build.sh native # the build machine's own platform, for local iterationLinux hosts are static musl so one binary runs on glibc and musl systems (Alpine included) unmodified. The XP build
applies winxp-compat.patch to the engine's four Vista-era threading calls and patch-mbedtls-xp.py to swap
BCryptGenRandom for CryptGenRandom, then verifies the result imports nothing newer than XP and fails if it does.
Result: a self-contained Windows executable of about 4.5 MB importing only KERNEL32, msvcrt, ADVAPI32 and WS2_32.
native-selftest.js (crypto vectors, deflate, a live TLS request) passes on the native Linux build and on the Windows
hosts under Wine. Not yet run on real Windows XP, Vista or 7 hardware: Wine implements the newer Windows APIs itself, so
it cannot reproduce a load failure that only exists there.
Compiling a host takes minutes and needs a POSIX shell and cross-compilers, which an ordinary Windows machine does not
have. So every host ships prebuilt and gzipped in quickjs/prebuilt/hosts/, together with the Windows 7 compatibility
DLLs in quickjs/prebuilt/win-compat/. A build takes the host from the cache, else from the prebuilt copy, else
compiles it.
A prebuilt host is trusted only while the sources it was built from are the sources on disk: its manifest carries a digest
of quickjs/native/, computed with line endings ignored so a CRLF checkout still matches. A changed source falls back to
compiling, and test/prebuilt.test.ts fails until the prebuilts are regenerated with bun run prebuilts.
Other things that keep repeat builds fast: converted ES modules and TypeScript files are cached by content, work is limited to a bounded number of files at once, and patched Windows 7 addons are cached by digest.
Embedded databases come in three kinds, and all three run:
- SQLite is built in.
node:sqlite,bun:sqliteand Deno KV work on every target with nothing to install, XP included (see the table above). The amalgamation is compiled into the host, single-threaded. - Databases that are Node-API addons load on the dynamically linked host, like any addon. Verified by running the same
program under Node.js and on the Graak engine and comparing what it prints: LevelDB (
classic-level), LMDB (lmdb), RocksDB (rocksdb-native) and SurrealDB, embedded (surrealdbwith@surrealdb/node, the Rust engine), on its in-memory, RocksDB (rocksdb://) and SurrealKV (surrealkv://) storage engines. - Databases you connect to (Postgres, MySQL, MongoDB, Redis, a SurrealDB server) are clients over
net,tls,cryptoand, for SurrealDB's remote protocol, the globalWebSocket.
rocksdb-native and its relatives call libuv directly, next to Node-API. On Linux the host exports the part of libuv's ABI they
use, laid out by libuv's own headers: uv_queue_work, uv_fs_open/close/read/write/mkdir/req_cleanup, uv_buf_init,
uv_err_name, uv_strerror. Work runs on OS threads and completes on the JavaScript thread. On the Windows hosts the exported
subset is broader still: the default loop (uv_run, uv_stop, uv_now, ...), handles, timers, uv_async/uv_idle/uv_prepare/
uv_check, uv_queue_work on a four-thread pool, the same fs basics, the thread and lock primitives (uv_mutex_*, uv_rwlock_*,
uv_cond_*, uv_sem_*, uv_thread_*, uv_key_*, uv_once), and the system and error helpers, all written against Win32 calls
Windows XP already has. Not provided on either platform: sockets, pipes, TTYs, processes, signals and fs events -- another addon
that needs one of those fails to bind, naming what it lacks.
A .node file is a shared library that imports napi_* functions from the process that loads it. Nothing in that
contract needs Node.js, so the host implements it: quickjs/native/napi.c exports the whole Node-API surface on top of
the QuickJS C API and loads the library with dlopen / LoadLibrary. Addons built on Node-API run unmodified, on the
architecture they were built for.
Verified for real: @napi-rs/canvas (Skia; the PNG is byte-identical to Node's), sharp (libvips),
better-sqlite3 and @gifsx/gifsx, bufferutil, utf-8-validate, and a purpose-built fixture addon covering
values, strings, objects, buffers, callbacks, exceptions, wrapped classes, references, BigInt, async work and
thread-safe functions, whose output is identical to Node's.
- A static host cannot load addons (a static executable has no dynamic loader). When a program needs one,
linux-modern-x64gets the dynamically linked host automatically (glibc, or musl when the addon is musl-linked), and the build says so. Addons that are only accelerators (msgpackr-extract,zlib-sync,mediaplex, ...) do not trigger this: their libraries fall back to JavaScript, so the portable static host is kept. - Windows hosts export the Node-API functions and the addon's delay-load hook binds to them.
- The addon still has to run on the target's OS. It is a native binary built for some Windows or glibc; on an older target loading fails with the system's own message, and the build warns for legacy targets.
- Alpine and iSH have dynamic musl hosts (
linux-x64-musl-dyn,linux-x86-musl-dyn), chosen automatically.
A prebuilt V8 addon (an older better-sqlite3, erlpack, zlib-sync, anything on NAN) cannot be loaded by anything
but Node.js, but its source is C++ against a documented API. So Graak ships an implementation of that API on top of
Node-API (quickjs/native/v8/) and graak compile rebuilds such a package from source against it, with the target's
cross toolchain:
- A
.nodefile is recognised as a V8 binary by the symbols it imports. - The package's
binding.gypis read (targets, sources, include_dirs, defines, flags, libraries, dependent static libraries, conditions on OS and arch) and compiled. - The result imports only
napi_*functions and replaces the prebuilt binary in the output.
Verified with erlpack (Discord's NAN addon), zlib-sync built from its unmodified source, NAN 2.29 itself and a raw-V8 fixture, each compared against Node.js running the same source.
Said plainly: a V8 binary with no findable source stops the build naming the package; the gyp reader is a subset
(actions and unusual expansions stop the build); it is the API addons use, not all of V8, so a gap is an ordinary
compile error; property interceptors are modelled with a JavaScript Proxy; and a static host cannot dlopen, so this
applies wherever a dynamic host exists.
A dependency that installs straight from git, or one nobody has run npm install against on this machine, is just a
binding.gyp and .c/.cpp files: no build/Release/*.node anywhere, for any platform. graak compile notices this
during project collection (a package with a binding.gyp, or "gypfile": true, and no matching prebuilt) and compiles
it before packaging, with NativeAddonCompiler (src/compiler/NativeAddonCompiler.ts):
- When the target is the machine's own OS and architecture and
node-gypplus Python are onPATH, that is tried first (node-gyp rebuild), because it is what the package's own install step would have run. - Otherwise -- a cross build (a different target, or a musl host), or node-gyp/Python missing -- the same
binding.gypreader V8AddonBuilder uses reads the target settings, and the target's owncc/c++compiles the sources directly against the vendored Node-API headers (quickjs/native/include/), the same headers and flagstest/fixtures/napi/addon.cis hand-compiled with in the test suite. No V8 shim is involved here: the source is already Node-API, only unbuilt, so it needs nothing else. The result is a.nodethat imports plainnapi_*functions, placed exactly where the package's ownindex.jsexpects it.
Verified with a from-source fixture addon (the same one used to verify the Node-API host itself) shipped as a
dependency with a binding.gyp and no prebuilt anything: graak compile compiles it and the packaged output produces
output identical to Node.js running the same source, with no Node.js on the target.
Limits: Linux (glibc and musl, native and cross via mingw's counterpart toolchains) is verified end to end; Windows
cross-compilation of a plain from-source addon reuses V8AddonBuilder's import-library trick (an addon's napi_* imports
are bound to the running graak-c.exe) but was not run under Wine in this session -- do that before relying on it for a
Windows target. binding.gyp support is the same subset as V8AddonBuilder's (see above): dependent static libraries and
OS/target_arch conditions work; actions and unusual gyp expansions stop the build naming what, rather than
guessing. A package with genuinely no toolchain wired up for the target (see TOOLCHAINS/MUSL_TOOLCHAINS in
V8AddonBuilder.ts) is reported as such.
A .wasm file the project ships gets a ready-made JavaScript binding next to it automatically, generated by reading
the module's own export table (src/compiler/WasmModuleInfo.ts, WasmShimGenerator.ts): Rust, Zig, C, or Go (TinyGo)
compiled to wasm32-unknown-unknown or wasm32-wasip1 runs through wasm3 (the host's own WebAssembly, see above) or
real WebAssembly/node:wasi on Node.js and Deno, with no hand-written glue. import { add } from "./lib.wasm.js"
next to a lib.wasm is enough; graak wasm-shim <file.wasm> [-o out.js] generates the same file standalone, to inspect
or commit it. Two shapes are recognised:
- A reactor: no imports, or WASI imports it never calls before the caller does. Its exports become plain
functions (
i32/i64/f32/f64need no marshaling); amemoryexport plus one of the common alloc/free export name pairs (alloc/dealloc,malloc/free,__wbindgen_malloc/__wbindgen_free) additionally getsallocString/readString/freeStringhelpers for the UTF-8-bytes-in-a-caller-owned-buffer convention most wasm32 crates use when they bring no string marshaling of their own. - A WASI command: imports
wasi_snapshot_preview1(or the olderwasi_unstable) and exports_startor_initialize. The shim builds aWASIinstance (node:wasi, which Graak implements too), runs the entry point once, and exposes whatever else the module exports.
A module needing anything besides WASI (an arbitrary env import object) cannot be bound automatically -- the shim
generator returns nothing for it rather than guessing, and the .wasm ships with no generated binding; write that
loader by hand and supply the import object yourself. The generated shim always uses the synchronous
WebAssembly.Module/WebAssembly.Instance constructors, not WebAssembly.instantiate, because the native host
converts every ES module to CommonJS before packaging and a synchronous require() cannot wait on top-level await.
Verified with real rustc output: a wasm32-unknown-unknown reactor (add, a float function) round-trips through
the generated shim under Node.js 24.21.0, 26.9.0, Deno, and the packaged Graak binary with no Node.js on the target,
byte for byte. A wasm32-wasip1 "hello world" command using Rust's standard library ran correctly under Node and Deno
through the same shim, but the bundled wasm3 refused to parse it (LEB encoded value overflow) -- a real ceiling of
the vendored library on a std-linked WASI binary of that size and shape, not a bug in the generator, and not chased
further in this session (wasm3's own C parser, not JavaScript). A lean, no_std/panic = "abort" WASI binary, or a
plain wasm32-unknown-unknown reactor, are the reliable path until that is looked at.
- Windows XP: the host imports nothing newer than XP (see above).
- Windows 7 and Vista and prebuilt addons: an addon is a DLL whose imports the OS binds at load. Measured on the real
prebuilds of lmdb, better-sqlite3, msgpackr-extract, @napi-rs/canvas, davey, mediaplex and sharp, the whole gap is five
functions:
WaitOnAddress,WakeByAddressSingle/All(Windows 8),ProcessPrng(Windows 10) andGetSystemTimePreciseAsFileTime(Windows 8). Sograak compilepatches those addons in place (src/compiler/Win7Compat.ts, layout unchanged): the API-set DLL andbcryptprimitives.dllimports are renamed to Graak's ownfgsynch.dll/fgprng.dll, shipped beside the addon where Windows looks first, andGetSystemTimePreciseAsFileTimeis renamed to the signature-identicalGetSystemTimeAsFileTime. Verified under Wine on the real Skia addon: with the shim present it loads and renders, with it removed it fails to bind. - The Universal C Runtime (
api-ms-win-crt-*, linked by libvips/sharp) is a Windows update on 7 (KB2999226).--ucrt-dir <Redist\ucrt\DLLs\arch>from a Windows SDK ships it app-local, which Microsoft permits. - The patcher only touches imports it knows how to satisfy; anything else fails with the system's own message.
npm, pnpm, Yarn, Bun and Deno all build every target. The manager is detected from packageManager in package.json,
then the lockfile (deno.lock and bun.lock included), then a bare deno.json, then the invoking environment, and can
be forced with --pm.
Deno projects. A directory with a deno.json, deno.jsonc or deno.lock is a Deno project (--pm deno forces
it), and it needs no package.json. Deno resolves imports in ways neither engine does (an import map, jsr:, npm:
and https: specifiers, redirects, top-level await), so Graak asks Deno itself, the way it asks Yarn for a
Plug'n'Play project and Bun for TypeScript: deno info --json reports the module graph Deno builds for the entry
point, with every module's cached file and every npm package's directory, and Graak builds from that graph.
- The program's own code,
jsr:packages andhttps:modules become one bundle (esbuild, answering every import from Deno's graph, so an import map, a version range or a redirect resolves as it does under Deno). Top-levelawaitsurvives because the bundle runs inside an async function,import.meta.url,.dirname,.filename,.mainand.resolve()are answered from where each file sits in the packaged application, and JSON imports and dynamicimport()work. npm:packages are not inlined. They are laid out as a realnode_modulestree beside the bundle, from Deno's own cache, so native addons,__dirnamereads and dynamic requires keep working, and the rest of the build (Windows 7 patching, V8 addon rebuilds, the ES module conversion) treats them like any other dependency.deno2.x must be on PATH at build time only, never on the device. The project is not modified: nodeno.lockornode_modulesis written into it (deno infoworks on a scratch copy of the lock).--offlinebuilds from Deno's cache alone.- The
Denonamespace (quickjs/runtime/deno-shim.js, inlined into the bundle, so it is the same on the Graak engine and on Node.js) is written over the Node.js API:Deno.readFile/readTextFile/writeFile/writeTextFile,stat,readDir,mkdir,remove,rename,copyFile,symlink,openandFsFile,makeTempDir/File,Deno.env,args,cwd,exit,Deno.serve(with TLS,onListen,signalandshutdown),Deno.upgradeWebSocketand a globalWebSocketclient,Deno.listen/connect/listenTls/connectTls,Deno.CommandandChildProcess,Deno.permissions(everything is granted, as withdeno compile -A), signals,Deno.errorswith Deno's classes and codes and the same message shape, andDeno.build/versionmatching the Deno used to build. Verified by running the same programs under real Deno and under Graak and comparing what they print: file system, environment, HTTP server and client, TCP, unix and UDP sockets, subprocesses, WebSocket, Deno KV, FFI andDeno.testcorpora are identical, and a project with an import map,jsr:,npm:, a JSON import, top-levelawaitand a file read beside the program runs identically on the Graak engine, on Node.js and as one Windows 7.exe(under Wine). - The parts of Deno that need more than a file or a socket are provided too.
Deno.openKvis Deno KV on SQLite: keys in Deno's order (bytes, strings, bigints, numbers, booleans), values that round-trip Date, Map, Set, RegExp, typed arrays, BigInt, errors and cycles,listwith prefix, range,reverse,limit,cursorandbatchSize,atomic()withcheck,set,delete,sum,min,maxandenqueue,expireIn,KvU64, versionstamps,enqueuewithdelay,backoffScheduleandkeysIfUndelivered,listenQueue,watch. It writes an ordinary SQLite file thatnode:sqliteopens (the default location is~/.graak/kv/; a URL to a remote database is not reachable).Deno.dlopenis FFI on libffi: scalars, 64-bit integers as bigints, buffers, pointers as opaque objects,UnsafePointer,UnsafePointerView,UnsafeFnPointer, callbacks (UnsafeCallback), structs passed and returned by value and static data; a program that calls it gets the dynamically linked host, because a static one cannot load a library.Deno.cronruns UTC schedules in cron syntax (steps, ranges, lists, month and weekday names, Deno's object form) withbackoffScheduleandsignal.Deno.watchFs,Deno.listen/connectwithtransport: "unix",Deno.listenDatagram(UDP), andDeno.testandDeno.benchcomplete it: the tests and benchmarks a program registers run after its main module and print whatdeno testanddeno benchprint (steps,ignore,only, the ERRORS and FAILURES sections, the summary, exit code 1 on failure), except where a failure is located, since a packaged program is one bundle. - Not the same as Deno: FFI runs on the Graak engine only (a Node.js build gets
Deno.errors.NotSupported, since Node.js has no foreign function interface); anonblockingFFI symbol returns a promise but the call itself blocks the event loop while it runs, and a callback invoked from another thread is not delivered;unixpacketsockets are not available; an expired KV entry is hidden at once (Deno's local database hides it when its cleanup runs);console.logprints objects the way Node.js does (single-quoted strings), not the way Deno does. - Which targets Deno covers.
graak info <target>andgraak targetssay whetherdeno compilebuilds a target itself ($canPackageOnDenosays it from a bot). For those five, either tool works; for the others Graak is the only way to a Deno program on that device. - Go shared libraries work through the same
Deno.dlopenpath as C, unmodified.go build -buildmode=c-sharedproduces an ordinary.so/.dll/.dylibplus a C header: cgo's_cgo_-prefixed runtime glue stays internal to the library, and each//exported function is a plain C ABI symbol (extern "C", no name mangling) — the same shapeDeno.dlopenalready loads for a hand-written C library, sofg_ffi.c'sdlopen/dlsym/libffi call path needed no change to support it (verified intest/goFfi.test.tsby building a real cgo library and calling it under the packaged host, comparing against real Deno). Two things to build one: it needsCGO_ENABLED=1and a C compiler on the build machine (never on the device the Graak binary ships to), and once a program calls into it the Go runtime's own goroutine scheduler and GC threads start inside the process — leave the library loaded for the process's life rather than closing it, since Go's runtime does not support being unloaded from a process (Deno.dlopen(...).close()on a Go library is undefined). Memory ownership follows the same rule as any C FFI call: a pointer a Go function returns (typically fromC.CString, which uses C'smalloc) must be freed with whatever the library itself exports for that purpose (its ownC.free-wrapping export), not with a function on the caller's side, since freeing across two different allocators is undefined behaviour whenever they differ. No legacy-Windows-specificlibgcccaveat applies beyond the ones dynamic linking already has (see "Native addons" above):-buildmode=c-sharedoutput links against the platform's C runtime the same way any other dynamically linked C library does.
graak compile main.ts --target win-legacy-x64 # Deno program for Windows 7, no Deno on the device
graak compile main.ts --target win-xp-x86 --engine native --strategy sea --output app.exe # one fileBun projects. bun build --compile is the quick path for a modern 64-bit desktop, but its output needs Bun's runtime
on the device. Graak builds every target for Bun projects, linux-armv7 included. TypeScript and JSX entrypoints are
transpiled automatically: on the Graak engine by Graak itself, on the Node.js engine with bun build --target=node --format=cjs --packages=external, so the installed node_modules your lockfile pinned are what ships (bun is needed
at build time only). The Graak engine provides bun:sqlite (SQLite is built into the host) but not the rest of Bun (Bun.*, other bun: modules): use node: APIs. On Node.js targets bun:sqlite (over node:sqlite), Bun.env/file/write/sleep/which/nanoseconds and
Bun.serve are polyfilled; Bun.password, Bun.hash, Bun.spawn and FFI throw at the point of use rather than
substituting a different algorithm.
Yarn Plug'n'Play. A PnP project has no node_modules to collect. Graak asks Yarn itself for one: it copies the
project into a temporary directory and runs YARN_NODE_LINKER=node-modules yarn install there with the project's own
pinned yarnPath, so the same yarn.lock resolves to a real tree, the original project is never touched and Yarn's
resolver is never reimplemented. Verified against a real Yarn Berry install.
Applies to builds that ship a Node.js runtime: win-x86, win-modern-x64, linux-armv7, linux-modern-arm64,
darwin-*, freebsd-x86, and any Graak-engine target moved back with --engine node, --node-binary or
--strategy sea|portable. The Graak engine needs none of this.
- Windows 7: Node.js v12.22.12, downloaded and verified automatically. Node's own tier list claims Windows 7 support through v13, but community reports of later builds crashing on real Windows 7 make 12.22.12 the safe pin.
- Windows Vista: v5.12.0, the last release that runs there. It is pre-ES6, below what code lowering can reach,
so the code ships unchanged and the build says so. See
graak info win-vista-x86. - iSH and FreeBSD:
apk/pkgalready have a current Node.js; the executable runs that install command itself on first launch. - Windows XP and 32-bit Linux have no automatable Node.js path (XP's last release predates ES6 and modern TLS;
32-bit Linux's last build is Node 12.16.3). Use the Graak engine,
--node-binary, orgraak runtimes add.
When the target runtime is older than Node.js 20, Graak rewrites the program so it can run there:
- Syntax. Every bundled JavaScript file is re-emitted for the target's exact language level with esbuild. ES modules
become CommonJS and
"type": "module"is dropped. Yournode_moduleson disk is never touched. - APIs. The missing platform surface is filled in at startup: Web Streams,
EventTarget,AbortController,Blob/File/FormData,DOMException,structuredClone,WeakRef, thenode:prefix,crypto.randomUUIDand the newerArray/String/Promisestatics, measured against a real Node 12.22.12. - Code generated at runtime. esbuild's WebAssembly build ships with the bundle (~3.6 MiB compressed) and lowers
source a program builds while running (ForgeScript does, via
new Function), memoised per template.
Deliberately not done: Intl.Segmenter sentence granularity throws instead of being approximated; WeakRef/FinalizationRegistry hold
strong references and never finalize; a dependency's engines.node floor is overridden and the build says so; below
Node.js 6 nothing is rewritten.
Old and 32-bit targets get two adjustments, derived from the target metadata: undici's SIMD parser is disabled
(UNDICI_NO_WASM_SIMD=1), and a native addon shim answers ERR_DLOPEN_FAILED only where a correct replacement exists:
| Package | On a legacy target |
|---|---|
bufferutil, utf-8-validate |
Replaced by pure JS with identical behaviour. |
sqlite3, better-sqlite3 |
Backed by built-in node:sqlite (Node >= 22.5). Rows go to the real database file. |
zlib-sync, msgpackr-extract, pg-native, mediaplex, opus |
Error passed through, so the library takes its own pure-JS path. |
lmdb, canvas, @gifsx/gifsx, sodium-native, @snazzah/davey, bcrypt, argon2 |
Load fails with an explanation. |
The last row is deliberate: a stub that returns blank images, drops database writes or hashes with a weaker algorithm leaves a program that looks healthy while losing data or its security guarantees.
# The entrypoint is JavaScript or TypeScript (a Node.js build needs it compiled first), or a folder of built files.
graak compile dist/index.js --target linux-modern-x64 # Graak engine, no Node.js in the output
graak compile dist/index.js --target ios-ish-x86 # Graak engine, static musl
graak compile dist/index.js --target win-legacy-x64 # Graak engine, Windows 7 patches applied
graak compile dist/index.js --target win-legacy-x64 --engine native --strategy sea # one .exe of about 5 MB
graak compile ./dist --target linux-modern-x64 --spa --port 8080 # a built website
graak compile dist/index.js --target win-modern-x64 # sea, official Node.js
graak compile dist/index.js --target win-vista-x86 --node-binary ./node-5.12.0/node.exe
graak compile main.ts --target win-legacy-x64 # a Deno project (deno.json), for Windows 7
graak targets [--pm <package manager>]
graak info win-legacy-x64 [--db sqlite] # engine, architecture, format, Node.js fallback, warnings
graak extensions # ForgeScript extensions a project uses, with legacy compatibility
graak inspect ./graak-out/app-linux-modern-x64
graak runtimes list [--target <target>]
graak runtimes add linux-x86 12.16.3 https://example.com/node-linux-x86.tar.gz --sha256 <hex>
graak runtimes remove linux-x86 12.16.3
graak version| Option | Effect |
|---|---|
-t, --target <name> |
Target device (see graak targets) |
-o, --output <path> |
Output file (a single-file build, or a Node.js SEA) or directory (a folder build) |
-e, --engine auto|native|node |
Which engine runs the program. auto follows the target. native forces the Graak engine (and refuses a target without one); with it, --strategy sea is one file. node forces a Node.js build |
-s, --strategy auto|sea|portable |
auto picks the target's default. Without --engine, sea or portable also moves a Graak-engine target onto Node.js |
--static, --spa, --port <n>, --host <addr>, --index <file> |
Static sites |
--pm <name> |
Package manager or runtime override (bun, deno, pnpm, npm, yarn) |
--node-binary <path> |
Use this Node.js runtime instead of the target's default. Also moves a Graak-engine target onto Node.js |
--node-version <ver> |
Official Node.js version to download (22 or 22.11.0) |
--native-libc musl|musl-dynamic|glibc |
Libc of the Graak host. musl (static) is the default and runs on glibc and musl systems; it cannot load addons, so a program that needs one gets a dynamic host automatically |
--ucrt-dir <dir> |
Redist\ucrt\DLLs\<arch> from a Windows SDK, shipped app-local for addons that need the Universal C Runtime on Windows 7 |
--offline |
Never download runtimes or fetch addon sources |
--include-dev |
Bundle devDependencies too |
--include-env |
Bundle .env files (they usually contain secrets) |
--allow-native-mismatch |
Build even when native addons are built for another platform (a trimmed Graak-engine build leaves those binaries out; --no-trim keeps them) |
--no-trim |
Graak engine: ship every collected file instead of only the ones the program can load (Trim) |
Everything the CLI does is available from Node.js, and importing Graak does not load ForgeScript:
const { BinaryPackager } = require("graak");
const result = await BinaryPackager.compile({
entrypoint: "dist/index.js",
target: "win-legacy-x64",
engine: "native",
strategy: "sea",
output: "graak-out/app.exe",
onLog: console.log,
});
console.log(result.outputPath, result.sizeBytes, result.warnings);graak also exports TargetDevice, TARGET_METADATA_MAP, PolicyEnforcer, QuickJsPackager, StaticSite,
LegacyTranspiler, Win7Compat, BinaryInspector, RuntimeRegistry and GraakError, with type declarations.
For projects built on ForgeScript, Graak is also a ForgeScript extension, kept in its own entry point so that the rest of the package does not depend on it:
const { ForgeClient } = require("@tryforge/forgescript");
const { Graak } = require("graak/forgescript");
const client = new ForgeClient({
// ...your options
extensions: [
new Graak({
allowCompile: false, // set true to enable $compileBinary
root: process.cwd(), // file arguments are confined to this directory
}),
],
});$compileBinary stays disabled unless allowCompile: true. The function reference lives in metadata/functions.json
(regenerate with bun run docgen).
| Group | Functions |
|---|---|
| Build | $compileBinary, $dbDriverCompat, $suggestDbDriver, $graakVersion |
| Binary | $binarySize, $sha256Binary, $verifyBinaryHeader, $generateSeaConfig |
| Policy | $isTargetSupported, $supportedTargets, $packageManager, $packagerType, $canPackageOnBun, $canPackageOnDeno |
| Target | $listPlatforms, $targetName, $targetDescription, $targetPlatform, $targetBits, $binaryArchitecture, $binaryExtension, $binaryFormat, $is32BitTarget, $is64BitTarget, $is32BitOrLegacy, $isArmTarget, $isIsh, $isLegacyWindows |
$packagerType returns native for a target on the Graak engine and sea or portable for the Node.js targets.
| Variable | Effect |
|---|---|
GRAAK_CACHE |
Cache directory (hosts, Node.js runtimes, converted modules, toolchains). Defaults to %LOCALAPPDATA%\graak\cache on Windows and ~/.cache/graak elsewhere |
GRAAK_UCRT_DIR |
Same as --ucrt-dir |
GRAAK_C |
Path of a built graak-c, for the test suite's native self-test |
GRAAK_QJS |
Path of a qjs binary, for the test suite's engine comparison |
GRAAK_NAN_DIR |
NAN sources, for the V8 addon tests |
GRAAK_HOME |
Where a Node.js build extracts its archive (default: beside the executable) |
GRAAK_NODE, GRAAK_DIR |
Read by the generated portable launchers to find their Node.js runtime |
GRAAK_APP_DIR, GRAAK_EXECUTABLE, GRAAK_JS_TARGET |
Set for a Node.js build's program by its launcher: the unpacked application directory, the executable, and the language level code was lowered to |
bun install
bun run typecheck && bun run typecheck:test && bun run build && bun run test && bun run checkThe repository is installed and driven with Bun (bun.lock). Its scripts are plain tsc and node commands, so npm, pnpm, Yarn
and Deno (deno task) run the same ones; only the lockfile is Bun's. bun test is Bun's own runner, not this suite: use
bun run test.
bun run test compiles first and runs everything under test/ with Node's test runner. Checks that need something extra
skip themselves when it is missing: a built host (GRAAK_C), qjs (GRAAK_QJS), the musl.cc toolchains on PATH, NAN
sources (GRAAK_NAN_DIR), deno (the Deno corpora and project compare against real Deno; the project needs the jsr and npm packages reachable or cached) and the fg-wine Docker image, which runs the Windows host (build it with docker build -t fg-wine -f test/docker/fg-wine.Dockerfile test/docker). dist/ is committed, so run
bun run build before committing.
Layout: src/compiler (collect, convert, package), src/structures (targets, errors), src/runtime (launchers and
polyfills for Node.js builds), src/integrations (database driver knowledge), src/forgescript (the optional
ForgeScript adapter), quickjs/native (the C host), quickjs/runtime (its JavaScript standard library) and
quickjs/prebuilt (shipped hosts and DLLs).
After any change under quickjs/native/ or quickjs/winxp-compat.patch, rebuild the shipped hosts (about seven
minutes, with the cross toolchains on PATH) and commit them:
bun run prebuilts- quickjs-ng, mbedTLS, miniz and wasm3 power the Graak host.
- esbuild converts and lowers code, and postject injects Node.js single executables.
- ForgeScript by the BotForge team, for the optional extension.
Licensed under GPL-3.0-or-later (see LICENSE).
