# SxfeScript and SXN, complete documentation Every documentation page from https://sxfescript.github.io/docs/, in full, in source order. The index with per-page links is at https://sxfescript.github.io/llms.txt. ============================================================================== # Quick start Source: docs/guide/quickstart.md URL: https://sxfescript.github.io/docs/quickstart/ ============================================================================== # Quick start `sxn` is a single binary. It runs `.sx` — this project's own language — plus `.ts`, `.js`, `.mjs` and `.cjs`, all directly, with no build step and nothing to configure first. ## Install macOS and Linux, arm64 or x64: ```sh curl -fsSL https://sxfescript.github.io/latest/install.sh | bash ``` Windows, arm64 or x64: ```powershell irm https://sxfescript.github.io/latest/install.ps1 | iex ``` Both drop the binary in `~/.sxn/bin` (`%USERPROFILE%\.sxn\bin` on Windows) and add that to your `PATH`. Open a new shell, then check it: ```sh sxn --version ``` ``` sxn 0.0.1 ``` Every release also ships plain `.tar.gz` and `.zip` archives on the [releases page](https://github.com/SxfeScript/sxfescript/releases), if you'd rather unpack one yourself. ## Your first program Put this in `hello.sx`: ```sx interface Repo { name: string; stars: i32; } const describe = (repo: Repo): string => `${repo.name} has ${repo.stars} star${repo.stars === 1 ? "" : "s"}`; console.log(describe({ name: "sxfescript", stars: 1 })); ``` ```sh sxn hello.sx ``` ``` sxfescript has 1 star ``` That is an ordinary interface and an ordinary annotation, and there is no `tsc` and no bundler in front of it. `sxn` parses the types itself and strips them as it goes. ## Ownership and borrows `.sx` is the same language with mutation and aliasing made explicit. `let mut` is a mutable owner, `let` an immutable one, `&` borrows a value shared, and `&mut` borrows it exclusively: ```sx interface Counter { hits: i32; } // &mut borrows the counter exclusively, so bump can change what it was // handed without taking ownership of it. function bump(c: &mut Counter): void { c.hits += 1; } let mut counter: Counter = { hits: 0 }; bump(&mut counter); bump(&mut counter); console.log(`counter: ${counter.hits}`); ``` ```sh sxn counter.sx ``` ``` counter: 2 ``` An interface whose fields are all primitives — `i32`, `f32`, `f64`, `bool` — describes a fixed-layout struct: declared field order, natural alignment, the same layout on every supported target. That is what code crossing into native memory needs. The syntax is parsed natively today. The full control-flow ownership pass that enforces every rule in [the language contract](../language/) is still being written, and [the implementation ledger](../implementation/) tracks exactly what is checked and what is only parsed. It is worth reading before you rely on a rule being enforced. ## An HTTP server `Sxn.serve` hands your function a `Request` and expects a `Response` back — the same pair of objects a handler gets on Cloudflare Workers, Deno or Bun: ```sx const server = Sxn.serve({ port: 3000 }, async (req: Request): Promise => { const url = new URL(req.url); if (url.pathname === "/echo") return Response.json(await req.json()); return new Response("hello from " + url.pathname); }); console.log(`listening on ${server.url}`); ``` ```sh sxn server.sx ``` ``` listening on http://127.0.0.1:3000 ``` `port: 0` asks the operating system for a free port instead, and `server.port` then tells you which one it picked. `server.stop()` shuts the listener down, so one process can serve and then go on to do something else. ## JavaScript and TypeScript run too Nothing above is required. `sxn` runs a plain `.js`, `.mjs`, `.cjs` or `.ts` file directly, and a `.sx` module can `import` any of them and vice versa. A `.sx` file that uses none of the extra syntax is just JavaScript with a different extension. ```js const runtime = typeof Sxn !== "undefined" ? "sxn " + Sxn.version : "something else"; console.log(`hello from ${runtime}`); ``` ```sh sxn hello.js ``` ``` hello from sxn 0.0.1 ``` TypeScript's erasable forms are all accepted: aliases, interfaces, `declare`, annotations, optional parameters, generics on functions, `as`/`satisfies`, and union types. `enum` and `namespace` are rejected on purpose rather than stripped, because both emit a real object at runtime in TypeScript, and quietly removing them would turn every use of their members into `undefined`: ``` SyntaxError: unsupported keyword: enum ``` ## Precompiling `sxn compile` writes bytecode that skips parsing on later runs: ```sh sxn compile app.sx -o app.sxbc sxn app.sxbc ``` `sxn --compile-cache app.sx` does the same thing automatically, building the cache on the first launch and reusing it afterwards. The measured gains, and the reason bytecode is not a safe format for untrusted input, are in [the bytecode spec](../bytecode/). ## Where to go next - [Examples](../examples/) — complete programs you can run, with their output. - [The runtime surface](../runtime/) — `fetch`, `Sxn.serve`, streams, crypto, FFI. - [Node compatibility](../node/) — what runs because it imitates Node. - [The CLI](../cli/) — every command and flag. ============================================================================== # Examples Source: docs/guide/examples.md URL: https://sxfescript.github.io/docs/examples/ ============================================================================== # Examples Every program on this page is a real file in [`examples/`](https://github.com/SxfeScript/sxfescript/tree/main/examples), and the output under each one is what it actually prints. The code below is inlined from those files when this page is built, so it cannot drift out of step with them. Clone the repo and run them, or paste one into a file and run that. They are all `.sx`, because that is the language this project is for. Everything a `.sx` file can do here, a plain `.js`, `.mjs` or `.ts` file can do too — the annotations and the ownership syntax are the only difference, and [the quick start](../quickstart/) shows the same program in each. ## Types and borrows [`examples/hello.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/hello.sx) ```sx // Run it: sxn examples/hello.sx // // This is a .sx file, so the type annotations below are parsed and stripped // by the runtime itself. There is no tsc, no bundler, and no build step -- // sxn reads this file and runs it. interface Repo { name: string; stars: i32; } const describe = (repo: Repo): string => `${repo.name} has ${repo.stars} star${repo.stars === 1 ? "" : "s"}`; console.log(describe({ name: "sxfescript", stars: 1 })); // `let mut` is a mutable owner, `let` an immutable one. `&mut` borrows a // value exclusively, so a function can change what it was handed without // taking ownership of it. interface Counter { hits: i32; } function bump(c: &mut Counter): void { c.hits += 1; } let mut counter: Counter = { hits: 0 }; bump(&mut counter); bump(&mut counter); console.log(`counter: ${counter.hits}`); ``` ```sh sxn examples/hello.sx ``` ``` sxfescript has 1 star counter: 2 ``` ## A fixed-layout struct [`examples/velocity.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/velocity.sx) An interface whose fields are all primitives (`i32`, `f32`, `f64`, `bool`) describes a struct with declared field order and natural alignment — the same layout on every supported target, which is what code crossing into native memory needs. ```sx interface Transform { x: f32; y: f32; z: f32; } const applyVelocity = (transform: &mut Transform, velocity: &Transform, dt: f32): void => { transform.x += velocity.x * dt; transform.y += velocity.y * dt; transform.z += velocity.z * dt; }; let mut pos: Transform = { x: 0.0, y: 10.0, z: 5.0 }; let vel: Transform = { x: 1.0, y: 0.0, z: 0.0 }; applyVelocity(&mut pos, &vel, 0.016); console.log(JSON.stringify(pos)); ``` ```sh sxn examples/velocity.sx ``` ``` {"x":0.016,"y":10,"z":5} ``` ## An HTTP server [`examples/server.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/server.sx) The handler receives a `Request` and returns a `Response`. `req.url` is absolute, so `new URL(req.url)` gives you the path and query, and `await req.json()` reads the body. ```sx // An HTTP server. Run it: sxn examples/server.sx // // The handler takes a Request and returns a Response, the same two objects a // handler on Cloudflare Workers, Deno or Bun receives. `port: 0` asks the OS // for a free port; pass a real one to pick it yourself. interface Note { id: number; text: string; } const notes: Map = new Map([[1, "the first note"]]); let mut nextId: number = 2; const server = Sxn.serve({ port: 0 }, async (req: Request): Promise => { const url = new URL(req.url); if (url.pathname === "/") { return new Response("try /notes"); } if (url.pathname === "/notes" && req.method === "GET") { const all: Note[] = [...notes].map(([id, text]) => ({ id, text })); return Response.json(all); } if (url.pathname === "/notes" && req.method === "POST") { const { text } = await req.json(); const note: Note = { id: nextId++, text }; notes.set(note.id, note.text); return Response.json(note, { status: 201 }); } return new Response("not found", { status: 404 }); }); console.log(`listening on ${server.url}`); // Call the server we just started, from the same process. const created = await fetch(`${server.url}/notes`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: "written by the example" }), }); console.log("POST /notes ->", created.status, await created.text()); const all = await fetch(`${server.url}/notes`); console.log("GET /notes ->", all.status, await all.text()); // Without stop() the listening socket keeps the process alive, which is what // you want for a real server and not for a script that has finished. server.stop(); ``` ```sh sxn examples/server.sx ``` ``` listening on http://127.0.0.1:56690 POST /notes -> 201 {"id":2,"text":"written by the example"} GET /notes -> 200 [{"id":1,"text":"the first note"},{"id":2,"text":"written by the example"}] ``` The port differs every run, because `port: 0` asks the operating system to pick a free one. Pass a real port number to choose it yourself. ## fetch and streams [`examples/fetch.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/fetch.sx) A response body is a real `ReadableStream`, so it can be piped and consumed a chunk at a time rather than only read whole. ```sx // fetch and Web Streams. Run it: sxn examples/fetch.sx // // fetch is the global one from the Fetch standard, backed by libcurl. The // response body is a real ReadableStream, so it can be consumed a chunk at a // time instead of all at once. const res: Response = await fetch("https://example.com/"); console.log(res.status, res.headers.get("content-type")); // Read it as text, in whole. const html: string = await res.text(); console.log(`${html.length} bytes`); // Or a chunk at a time, decoding as the bytes arrive. const streamed: Response = await fetch("https://example.com/"); let mut chunks: number = 0; let mut characters: number = 0; for await (const chunk of streamed.body.pipeThrough(new TextDecoderStream())) { chunks += 1; characters += chunk.length; } console.log(`${chunks} chunk(s), ${characters} characters`); ``` ```sh sxn examples/fetch.sx ``` ``` 200 text/html 559 bytes 1 chunk(s), 559 characters ``` ## Files [`examples/files.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/files.sx) `Sxn.file` and `Sxn.write` are the runtime's own file I/O. The `node:fs` import is the compatibility layer reading the same file back, for code that already expects Node. ```sx // Reading and writing files. Run it: sxn examples/files.sx // // Sxn.file(path) and Sxn.write(path, data) are the runtime's own file I/O. // The node:fs import below is the compatibility layer reading the same file, // for code that already expects Node. import { tmpdir } from "node:os"; import { join } from "node:path"; import { readFile } from "node:fs/promises"; const path: string = join(tmpdir(), "sxn-example.txt"); await Sxn.write(path, "written by the example\n"); const file = Sxn.file(path); console.log(JSON.stringify(await file.text())); // The same file through the Node surface. console.log("via node:fs ->", JSON.stringify(await readFile(path, "utf8"))); console.log("sxn version:", Sxn.version); ``` ```sh sxn examples/files.sx ``` ``` "written by the example\n" via node:fs -> "written by the example\n" sxn version: 0.0.1 ``` ## Calling a C function [`examples/ffi.sx`](https://github.com/SxfeScript/sxfescript/blob/main/examples/ffi.sx) `Sxn.ffi(library, symbol, argumentTypes, returnType)` returns a callable function, through libffi and `dlopen`. ```sx // Calling a C function directly. Run it: sxn examples/ffi.sx // // Sxn.ffi(library, symbol, argumentTypes, returnType) returns a callable // SxfeScript function, through libffi and dlopen. This is an engine // capability, not a Node one -- see spec/NATIVE.md for the type list and for // what is deliberately unsupported (structs by value, callbacks, variadics). type LibraryName = string; const libm: LibraryName | undefined = { darwin: "libSystem.B.dylib", linux: "libm.so.6", win32: "msvcrt.dll", }[process.platform]; if (!libm) throw new Error(`no libm name known for ${process.platform}`); const pow = Sxn.ffi(libm, "pow", ["f64", "f64"], "f64"); const sqrt = Sxn.ffi(libm, "sqrt", ["f64"], "f64"); console.log("pow(2, 10) =", pow(2, 10)); console.log("sqrt(144) =", sqrt(144)); ``` ```sh sxn examples/ffi.sx ``` ``` pow(2, 10) = 1024 sqrt(144) = 12 ``` Structs by value, callbacks and variadics are rejected rather than half-supported. [The native-code spec](../native/) has the full type list and the reasoning. ============================================================================== # CLI reference Source: spec/CLI.md URL: https://sxfescript.github.io/docs/cli/ ============================================================================== # SXN command contract - `sxn file.sx|ts|js|mjs|cjs|sxbc [args...]` executes a file directly. - `sxn [--memory-report] [--leak-check] [--compile-cache] [args...]` runs a file with diagnostics on, or (`--compile-cache`) via a bytecode cache built and reused across launches -- see `spec/BYTECODE.md`. - `sxn compile [-o out.sxbc] [--strip]` compiles a file to bytecode for distribution, without running it. `spec/BYTECODE.md`. - `sxn run [script] -- [args...]` executes a `package.json` script. - `sxn install` installs dependencies without lifecycle scripts. - `sxn add [--dev] package[@range]` adds and installs a dependency. - `sxn remove package` removes a dependency. - `sxn init` creates a minimal package. - `sxn lsp --stdio` runs the language server transport. Extensionless resolution order (an import or require with no extension, or a bare directory) is `.sx`, `.mjs`, `.js`, `.cjs`, `.json`, `.node`, `.ts`, then the same list again under `index.*` for a directory. Lifecycle hooks are disabled unless their package is explicitly named in the top-level `trustedDependencies` array. ============================================================================== # Language contract Source: spec/LANGUAGE.md URL: https://sxfescript.github.io/docs/language/ ============================================================================== # SxfeScript language contract SxfeScript uses `.sx`. JavaScript is the host language; ordinary JS values keep ordinary JS semantics. Types that can be erased without generating runtime code are accepted: type aliases, interfaces, `declare`, type-only exports, annotations, optional parameters, generics on functions, `as`/`satisfies`, and union types. `enum` and `namespace` are rejected deliberately rather than erased -- both emit a runtime object in real TypeScript, so silently stripping them would turn every use of their members into `undefined`. JSX, decorators, parameter properties, generic classes, and non-null assertions are rejected because they are simply not implemented yet, not because they've been ruled out. `safe` is an optional contextual qualifier for `let` and `const`. It marks a binding as type-stable for runtime validation and optimization; ordinary bindings remain dynamic. `safe let` follows the ownership rules below, while `safe let mut` allows reassignment only within its declared or inferred type. Safe object shapes reject property addition/deletion and incompatible writes. The compatibility transformer erases this qualifier; the native parser is responsible for attaching its runtime descriptor. Primitive FFI declarations use an explicit unsafe boundary: ```sx unsafe extern add(i32, i32): i32 from "add.dylib"; ``` Native parsing does not implement this lowering yet and rejects `extern` declarations with an explicit "not yet supported" error rather than mis-parsing them; the standalone compatibility transformer (src/frontend.c) lowers the declaration to ```js const add = Sxn.ffi("add.dylib", "add", "i32, i32", "i32"); ``` which is a call that now works -- see `spec/NATIVE.md` for the type list and what it does with pointers and strings. Structs by value, callbacks and variadics are still rejected there, because each needs ownership rules this document has not written down. ## Ownership - `let mut value: T` creates a mutable owner. - `let value: T` creates an immutable owner. - `&value` creates a shared lexical borrow. - `&mut value` creates an exclusive lexical borrow and requires a mutable owner. - Passing, assigning, returning, or capturing an affine value by value moves it. - A borrow cannot be returned, stored in a longer-lived value, or captured. - `unsafe` permits typed JS/native interop but never disables runtime alias locks. `i32`, `f32`, `f64`, `bool`, and ordinary JavaScript values are copyable. Primitive-only interfaces define affine fixed-layout structs. A literal becomes such a struct only in an explicit annotation, typed argument, or typed return context. At control-flow joins, a value moved on any reachable branch is considered moved. Loop-carried owners must be reinitialized on every continuation path. Borrowed affine values cannot cross `await`. ## Layout Fields retain declaration order. `bool` has size/alignment 1, `i32` and `f32` have size/alignment 4, and `f64` has size/alignment 8. Each field and final struct size are padded to natural alignment. This layout is identical on all supported desktop targets. ============================================================================== # ABI Source: spec/ABI.md URL: https://sxfescript.github.io/docs/abi/ ============================================================================== # Sxfe host ABI The stable public C surface starts in `include/sxfe.h`. Layout descriptors are finalized before registration. Typed native calls receive pointers only for the duration of the call. Shared pointers are read-only; mutable pointers are exclusive. Hosts must not retain either pointer. The production engine will expose distinct `sx_*` bytecodes for allocation, move, shared borrow, mutable borrow, release, field access, and owned drop. QuickJS `OP_drop` remains untouched because it is an operand-stack operation. Moving a layout value into JavaScript consumes it and boxes a copy. Borrowing it into JavaScript creates a revocable proxy. Typed JavaScript objects crossing into SX use shared/exclusive header locks, and incompatible property writes throw `TypeError`. FFI declarations are unsafe by default and are expected to use the platform C ABI. The initial syntax is `unsafe extern name(types): return from "library"`. Library handles must be runtime-owned and remain live until all wrappers and callbacks are released; native pointers may not outlive a call unless an explicit ownership type is added. What is implemented today, and where it sits relative to the Node compatibility layer, is `spec/NATIVE.md`. ============================================================================== # Bytecode Source: spec/BYTECODE.md URL: https://sxfescript.github.io/docs/bytecode/ ============================================================================== # Precompiled bytecode: `.sxbc` `sxn` can skip parsing a file entirely and run its already-compiled bytecode instead. This exists for two different reasons that happen to share one mechanism: - **Distribution.** `sxn compile app.sx` produces `app.sxbc`; ship that instead of the source and there is nothing left to parse on the machine that finally runs it. `--strip` drops the compiling machine's own file paths from the output, for when the source shouldn't be reconstructible from a stack trace. - **Startup, on a large file.** `sxn --compile-cache app.sx` compiles once, caches the result next to the source, and reuses it on every later launch until the source changes. This is the same idea already applied to this runtime's own bootstrap (see the README's benchmark section), turned into something a user's own script can opt into. Both produce and consume the same file format, so `sxn app.sxbc` runs either one's output directly. ## Is it worth it for your script? Measured on this runtime's own hardware (Apple M4, Release build), median of 9 runs, whole-process wall clock including startup: | Script | From source | From bytecode | Saved | |---|---:|---:|---:| | `console.log("hi")` | 7.8 ms | 6.9 ms | 12% | | 32k-line generated file (618 KB) | 15.2 ms | 9.6 ms | 37% | Skipping the parse always saves something, because there is always a parse to skip — but it scales with how much there is to parse. A one-line script gets a small, real win from skipping the tokenizer and AST setup entirely. A large generated file, a bundled app, or a big TypeScript-emitted script gets a large one. `--compile-cache` is the flag to reach for once a script is big enough, or launched often enough, that the difference shows up in something you're measuring; for a small script run once, it's not going to move anything you'd notice. ## `sxn compile [-o out.sxbc] [--strip]` ```sh sxn compile app.sx # writes app.sxbc next to it sxn compile app.sx -o dist/app.sxbc sxn compile app.sx --strip -o dist/app.sxbc # no local paths in the output ``` Works on anything `sxn` can run as an entry point: `.sx`, `.ts`, `.js`, `.mjs`, `.cjs`, module or CommonJS, decided the same way running it directly would decide (`spec/NODE.md`). The output name defaults to the input's name with its extension replaced by `.sxbc`. `--strip` removes line-number and local-variable debug tables (so a stripped error reports a bytecode offset, not a source line) and, separately, embeds the source's bare filename instead of its full path at compile time, so nothing about the machine or directory the source lived in survives into the shipped file. Verify what you're about to ship with `strings out.sxbc` if that matters to you. ## `sxn --compile-cache [args...]` Runs `file` exactly as `sxn file` would, except: before running, it checks for an `.sxbc` cache next to the source. If the cache is missing or older than the source (by mtime), it compiles fresh and writes the cache; either way, execution then runs from bytecode. A script invoked repeatedly parses once, not on every launch — the common case for a CLI tool people run often, or a dev server that restarts on every save without its own source having changed on most of those restarts. The cache is invisible to the script itself: `process.argv` and `__filename` still show the original source path, not the internal `.sxbc` file. ## `sxn app.sxbc` Runs a `.sxbc` file directly, as if it were the source it was compiled from. `require()` and `import` inside it resolve normally, against the directory the `.sxbc` file itself sits in. ## Format and limits A `.sxbc` file is 5 bytes of header — a magic number this runtime checks before trusting the rest as bytecode, so a corrupt or foreign file fails with a clear message rather than a confusing one from deep inside the engine — followed by QuickJS's own serialized bytecode for either a compiled module or a compiled CommonJS wrapper function. The format is tied to this runtime's exact build (the same `BC_VERSION` dependency the lazily-loaded builtins have, `spec/IMPLEMENTATION.md`): a `.sxbc` compiled by one version of `sxn` is not guaranteed to load in another, and a version mismatch is reported rather than misread. **Only compile trusted code.** `JS_ReadObject` with bytecode enabled is, by QuickJS's own documentation, not a safe format to parse untrusted input — unlike source text, a crafted bytecode blob can misdirect the interpreter directly. Compile your own code, or code you already trust as source; don't treat a `.sxbc` from an untrusted party as safer to run than the `.js`/`.mjs`/`.cjs` it might have come from. A `.sxbc`'s dependencies (whatever it `import`s or `require`s) still resolve and load as ordinary source at run time — compiling one file does not pull its dependency tree into the same blob. Compiling a whole app ahead of time currently means compiling each of its own files individually; there is no bundler step here. ============================================================================== # Runtime surface Source: spec/RUNTIME.md URL: https://sxfescript.github.io/docs/runtime/ ============================================================================== # The runtime surface This is what `sxn` gives you independent of Node compatibility: the WinterCG web APIs (45 of 55 names in the common surface), the `Sxn` host namespace, and the engine capabilities that go with it. `spec/NODE.md` is the other half — what runs because it imitates Node. The split matters because only this half travels when the engine is embedded elsewhere (`spec/NATIVE.md` explains why for the native-code case specifically). If you're choosing what to build against: code written to this surface plus `spec/NODE.md`'s CommonJS/ESM loader runs on `sxn`, in a browser Worker, and on Cloudflare Workers/Deno/Bun without a compatibility shim, because it's the same surface those runtimes implement. ## Script kinds and entry points `sxn file.sx` runs an SxfeScript file — ordinary JavaScript plus explicit mutation, affine values, and borrow sigils, parsed natively with no separate transform step (`spec/LANGUAGE.md`). `sxn file.ts` strips TypeScript types and runs the result, also natively, with no build step. `sxn file.js` / `.mjs` / `.cjs` run plain JavaScript. All four import each other freely: a `.sx` module can `import` a `.ts` module and vice versa. Module-or-script is decided the way Node decides it — see spec/NODE.md — with one exception: `.sx` and `.ts` are always modules, because type stripping is this project's own feature and has always meant ESM. ## `fetch` A global `fetch(url, options)`, backed by libcurl, with methods, headers, redirects, and streaming request and response bodies. `Sxn.fetch` is the same function reachable through the host namespace, for code that wants to be explicit about where it's calling. - `Request`, `Response`, `Headers`, `URL`, `URLSearchParams` — the standard classes, including `Response.json/error/redirect`, `Request.clone`, `Headers.getSetCookie`, and the one exception the Fetch spec itself carves out: repeated `Headers.append("Set-Cookie", …)` calls stay separate instead of folding into one comma-joined value. - A string body streams; `for await (const chunk of res.body)` and `res.body.pipeThrough(new TextDecoderStream())` both work on a response. - `FormData`, `File`, `Blob` for multipart bodies. ## Sxn.serve — the HTTP server ```sx const server = Sxn.serve({ port: 0 }, async (req: Request): Promise => { const url = new URL(req.url); if (url.pathname === "/echo") return Response.json(await req.json()); return new Response("hi"); }); server.port; // the port the OS chose, since `port: 0` asked it to pick server.url; // "http://127.0.0.1:PORT" server.stop(); ``` The handler receives a `Request` — `req.url` is absolute, so `new URL(req.url)` gives you the path and query, and `req.text()`/`req.json()`/`req.arrayBuffer()` read the body. It returns a `Response`, `Sxn.serve`'s own SSE helper, a WebSocket upgrade, or a plain `{ statusCode, headers, body }` object, which is the shape the native layer speaks and `node:http` is built on directly. Response headers are emitted in declaration order; an array value repeats the header (the multi-`Set-Cookie` case), and `Content-Length`/`Connection` are filtered since those describe the framing rather than the payload the handler wrote. The returned handle reports `port`, `url`, and a `stop()` that lets a process serve and then do something else, rather than block forever the way a bare listener would. ## Web Streams `ReadableStream`, `WritableStream`, `TransformStream`, and both queuing strategies, plus `TextEncoderStream`/`TextDecoderStream` built on them. A fetch response body is a real `ReadableStream`, not a stand-in, so `pipeThrough`, `pipeTo`, and `for await` all work on one. Not yet implemented: `CompressionStream`/`DecompressionStream` (`node:zlib` covers the same ground synchronously — see spec/NODE.md) and the BYOB reader. ## Crypto `crypto.getRandomValues`, `crypto.randomUUID`, and `crypto.subtle` — the Web Crypto surface, backed by OpenSSL. `node:crypto`'s `Hash`/`Hmac`/ `randomBytes`/`timingSafeEqual` cover the synchronous, Node-flavored version of the same digests; see spec/NODE.md. ## Structured data and messaging `structuredClone`, `MessageChannel`/`MessagePort`/`MessageEvent`, `Event`/`EventTarget`/`CustomEvent`, `AbortController`/`AbortSignal`, `DOMException`. `queueMicrotask`, `setTimeout`/`setInterval` and their `clearX` counterparts, `performance.now` (bound directly to its C primitive, not wrapped — see the README benchmarks for why that matters). Not implemented: `URLPattern`, `BroadcastChannel`, `Worker`, `WebSocket` as an *outbound client* (the server side — upgrading an incoming connection to a WebSocket from a `Sxn.serve` handler — works), `ErrorEvent`, `PromiseRejectionEvent`, and `Intl`. ## `Sxn.ffi` — calling a C function ```sx const pow = Sxn.ffi("libSystem.B.dylib", "pow", ["f64", "f64"], "f64"); pow(2, 10); // 1024 ``` Backed by libffi and `dlopen`. Full type list, pointer/string handling, and what's deliberately unsupported (structs by value, callbacks, variadics) are in `spec/NATIVE.md`, along with why this is the half of native-code support that belongs to the engine rather than to Node compatibility. ## Other `Sxn.*` entries `Sxn.file(path)` and `Sxn.write(path, data)` for file I/O in the Bun-style idiom; `Sxn.memoryUsage()`; `Sxn.version`. ## What's deliberately not here Anything that only makes sense with a machine-code tier — a JIT, or `process.dlopen`/`.node` addons — lives in the Node-compatibility layer instead, not here, precisely so a build of this runtime that drops that layer loses nothing on this side. See `spec/NATIVE.md` for the reasoning and `spec/NODE.md` for what that layer covers. ============================================================================== # Node compatibility Source: spec/NODE.md URL: https://sxfescript.github.io/docs/node/ ============================================================================== # The Node-compatibility layer This is what makes `sxn` usable as a Node alternative: CommonJS, the `node:` builtins, and native-addon loading. It's the half of the runtime that a mobile or embedded build can drop entirely without losing anything on the `spec/RUNTIME.md` side — `spec/NATIVE.md` explains why that split exists for the native-code case, and the same reasoning applies to this whole layer: Node emulation is dead weight to an embedder with no Node surface of its own. ## Running a file the way Node runs it `sxn` decides module-or-CommonJS the way Node does: `.mjs`/`.mts` are always modules, `.cjs` is always CommonJS, and a plain `.js` file or an extensionless one (every CLI an npm package ships) follows the nearest `package.json`'s `"type"`, defaulting to CommonJS. A `#!/usr/bin/env node` shebang line is stripped before evaluation, the way Node strips it, so those extensionless CLIs run directly: `sxn ./node_modules/.bin/whatever` works. A CommonJS module gets `require`, `module`, `exports`, `__filename`, and `__dirname`, wrapped exactly the way Node wraps it. `require.resolve(spec)` exists on every `require`. `require("node:module").createRequire(path)` returns a `require` anchored at that path's directory rather than the caller's — get this wrong and it resolves the wrong package's siblings. ## Module resolution Bare specifiers resolve through `node_modules`, walking up from the importing file — plain packages, scoped packages (`@scope/name`), and subpath imports. A package's `exports` field is read for the `.` subpath, checking conditions in the order `import`, `module`, `default`, `require`, `node`, then falling back to `module`, then `main`. Circular `require` sees the same partially filled `exports` a cycle sees in Node, rather than recursing forever. ## `.node` addons `require("./thing.node")` and the `process.dlopen` it calls under the hood both work, through a Node-API implementation built on QuickJS — full detail, including what's implemented and what isn't, is `spec/NATIVE.md`. It's listed here because it's the other reason this layer exists as a separate, droppable piece: on iOS you can't `dlopen` code that arrived after the app was signed, so this half of native-code support is inherently a desktop-and-server capability, unlike `Sxn.ffi`. ## `node:` builtins 24 of the ~37 Node ships. What each one covers, briefly, and where it's worth knowing the gap: | Module | Covers | |---|---| | `assert`, `assert/strict` | The standard assertion functions. | | `buffer` | See below — this one gets its own section. | | `crypto` | `Hash`, `Hmac` (standard construction over the digest primitive), `randomBytes`, `randomUUID`, `timingSafeEqual`. | | `events` | `EventEmitter`, including the mixin pattern (`Object.assign(fn, EventEmitter.prototype)`) Express uses, where `_events` is created lazily on first `on()`/`emit()` rather than in a constructor that never runs. | | `fs`, `fs/promises` | File I/O, sync and promise-based. | | `http` | `createServer`, `IncomingMessage`, `ServerResponse`, `ClientRequest`, `STATUS_CODES`, `METHODS`. The request body defers behind `_read` rather than pushing eagerly, because a body-parser attaches its listener after the handler returns — push first and it gets nothing. | | `module` | The `Module` constructor (what `require('module').prototype` expects), `createRequire`, `builtinModules`, `isBuiltin`. | | `net` | `isIP`/`isIPv4`/`isIPv6`, including IPv6 zone-index stripping (`fe80::1%eth0`). `Socket`/`Server` are not implemented and throw. | | `os`, `path`, `querystring`, `url`, `util` | The usual surface — `inspect`, `format`, `promisify`, `deepEqual`, POSIX/Win32 path handling, and so on. | | `perf_hooks` | Enough for timing code that reads `performance.now`-equivalent values. | | `process` | `platform`, `arch`, `version`/`versions`, `stdout`/`stderr`/`stdin`, `hrtime`, `emitWarning`, `uptime`, `pid`, `env`, `argv`, `dlopen`. | | `stream`, `stream/promises` | `Readable`/`Writable`/`Duplex`/`Transform`/`PassThrough`, `pipeline`, `finished`. The module export is the `Stream` function itself (some packages `require('stream')` and call it as a constructor), and `Readable` supports real `pipe`/`unpipe` — the latter matters because `finalhandler` calls it on every response, piped or not. | | `string_decoder`, `timers`, `timers/promises`, `tty` | Small, focused shims. | | `zlib` | `gzipSync`/`gunzipSync`/`deflateSync`/`inflateSync` and the stream equivalents (`createGzip` etc.), over the zlib already linked in. No `promises` namespace — Node doesn't have one either. | Not implemented: `child_process`, `cluster`, `dns`, `http2`, `https`, `readline`, `stream/web`, `tls`, `v8`, `vm`, `worker_threads`, `inspector`, `async_hooks`. `child_process` is the one that stops `next build` today — see `spec/NATIVE.md`'s account of running Next.js's own compiler for the full trace of what does and doesn't stand in the way. ## Buffer `Buffer` extends `Uint8Array` and matches Node's encoding behavior, verified against Node's own output rather than against itself — a divergence in either runtime fails the fixture: - `utf8`/`utf-8`, `hex`, `base64`, `base64url`, `latin1`/`binary`, `ascii`, `ucs2`/`ucs-2`/`utf16le`/`utf-16le` — every encoding name, case-insensitive, in both directions. - `hex` decoding stops at the first invalid pair rather than throwing, the way Node's own reader does (unlike the standard `Uint8Array.fromHex`, which throws). - `base64` decoding skips characters it can't use, stops at `=`, accepts either alphabet, needs no padding, and reads one byte per UTF-16 code unit — which is why a multi-byte character truncates a base64 string early: its high surrogate half masks down to `=`. - `Buffer.byteLength`, `compare`, `equals`, `concat`, `toJSON` (Node's `{type:"Buffer",data:[...]}` shape). ## Encoding-name and Buffer performance Two things specific to this layer are worth knowing if you're profiling Buffer-heavy code: a literal encoding string (`"utf-8"`, `"hex"`) at a call site is recognized by pointer identity against the atom table rather than by hashing and comparing, and `Buffer.byteLength` computes the UTF-8 byte count directly rather than encoding the string to measure it. Both are covered in more depth, with numbers, in the README's benchmark section. ============================================================================== # Native code Source: spec/NATIVE.md URL: https://sxfescript.github.io/docs/native/ ============================================================================== # Calling native code Two things in this runtime call into machine code, and they sit on opposite sides of a line that matters for where ArcSX is going. | | `Sxn.ffi` | `.node` addons | |---|---|---| | Lives in | `src/ffi.c` | `src/napi.c` | | Installed by | the runtime's own `Sxn` surface (`src/network.c`) | the node: layer (`src/node.c`) | | Direction | JavaScript calls out to a C function | a C library calls back into the host | | Backed by | libffi + `dlopen` | Node-API implemented on QuickJS | | Goes to Rayact | yes | no | ## Which side of the fence, and why The question that decided this is what happens when ArcSX is folded into Rayact. Rayact embeds quickjs-ng 0.15.0 — the same base this fork started from, with about 350 lines of its own on top — so the swap is a small delta rather than a re-port, and whatever these two features are attached to comes along with it. **Rayact already loads native code in its engine core.** `native/core/ rayact_module_abi.h` (ABI version 8) defines a plugin as a shared library that the loader `dlopen`s to call `rayact_module_register`, and `native/desktop/plugin_loader.cpp` is compiled verbatim into the Android `.so` as well as the desktop binary. `native/desktop/` is a misnomer: it is the shared core, and mobile is a first-class consumer of it. There is no "desktop-only capability" tier anywhere in that tree. The gating that exists is per-platform *implementation* — and where a platform cannot `dlopen` at all, which is iOS, the answer is not "this capability is unavailable" but `linkage: static` with weak-symbol registration (`native/ios/ios_plugins_register.cpp`) behind the same entry point. So calling native code is not, in this family of projects, a desktop luxury. It is core, and it is gated by *how* a library is linked rather than by *whether* the capability exists. `Sxn.ffi` belongs there. **Rayact has no Node layer to put anything in.** Its JavaScript environment is browser-like: `window`, `self`, a stub `navigator`, and no `process`, no `Buffer`, no `require`. Its bundler actively steers away from Node builds (`packages/rayact-dev-server/src/bundler.ts`: *"resolution picks the Node build of dual-build packages … which crash in QuickJS"*). Putting FFI behind a Node-compatibility shell would mean building that shell in Rayact for no other reason, and Node-API in particular would be dead weight next to a module ABI Rayact already has, with autolinking, manifest platform gating and SHA-256 artifact verification. Hence the split. `Sxn.ffi` is an engine capability that travels; the `.node` loader is Node emulation that does not. A build that wants the runtime without the Node surface drops `src/napi.c` and the vendored headers and loses nothing else. Worth saying plainly, because it is the reason the mobile runtime is treated as QuickJS rather than as Node: on iOS you cannot `dlopen` code that arrived after the app was signed. An npm-installed addon is out there no matter how complete Node-API becomes. A library bundled into the app is not. ## `Sxn.ffi(library, symbol, argTypes[, returnType])` Returns a callable. The libffi call interface is prepared once, here, so the returned function is the only thing on the hot path. ```sx const pow = Sxn.ffi("libSystem.B.dylib", "pow", ["f64", "f64"], "f64"); pow(2, 10); // 1024 ``` An empty library name means this executable, which is how a program reaches libc and its own symbols without naming a platform-specific file. Handles are opened once and never closed: a wrapper that outlived its library would call into unmapped memory, and nothing tracks that lifetime yet. Types: `void` `bool` `i8` `u8` `i16` `u16` `i32` `u32` `i64` `u64` `f32` `f64` `pointer` `cstring`. The C spellings `int`, `unsigned`, `long`, `float`, `double`, `char`, `size_t`, `ptr` and `string` are accepted too, so a declaration can be copied out of a header. `void` alone as the argument list means the function takes nothing. - 64-bit integers cross as **BigInt** in both directions. A double cannot carry one exactly, and silently losing the low bits of a handle or a size is worse than making the caller be explicit. - `pointer` accepts a typed array or an ArrayBuffer and passes the address of its bytes — a view passes its own offset — so out-parameters work. `null` and `undefined` pass NULL. A BigInt passes as a raw address. A returned pointer comes back as a BigInt, or `null`. - `cstring` converts a JS string to a temporary UTF-8 buffer that lives exactly as long as the call. A returned `char*` is copied into a JS string and **not freed** — a function that returns owned memory needs its own free call, declared separately. Not supported, and rejected rather than half-done: structs by value, callbacks into JS, and variadics. Each needs ownership rules this runtime has not written down (`spec/ABI.md`). The `unsafe extern` declaration in `spec/LANGUAGE.md` lowers to exactly this call: ```sx unsafe extern pow(f64, f64): f64 from "libSystem.B.dylib"; // const pow = Sxn.ffi("libSystem.B.dylib", "pow", "f64, f64", "f64"); ``` ## `.node` addons `require("./thing.node")` works, and so does `process.dlopen(module, path)`, which is what `require` calls. The shape of this problem is the reverse of FFI, and it is worth stating because "we have FFI, so we can load addons" does not follow. An addon exports one symbol, `napi_register_module_v1`, and *imports* around seventy `napi_*` functions that the host must provide — `next-swc` imports 67, sharp 53. Loading one is not a matter of calling into a library; it is a matter of being the library it calls into. So `src/napi.c` is ordinary exported C, and the executable is linked with `ENABLE_EXPORTS` so `dlopen` can resolve those imports back to it. `third_party/node-api/` holds Node's own four headers, copied verbatim, so an addon sees exactly the declarations it was compiled against. A `napi_value` is a JSValue owned by the innermost handle scope. QuickJS is refcounted rather than tracing, so a scope only has to release its values on close — simpler than the same thing on V8, and it means an addon that leaks handles leaks memory rather than corrupting anything. Two details are worth writing down, because both looked like addon bugs. A `napi_value` is a pointer to the slot holding its JSValue, so slots must never move: a growable array would relocate every handle the addon still held the moment it needed one more. Scope storage is therefore fixed blocks, allocated and never resized. Small addons never notice; a large one fails immediately and confusingly. A handle used *after* its scope closes is the other half of the same hazard, and it is the addon that is wrong rather than the runtime. Release cannot afford to check every read. The assertions build can, so there a closed scope keeps its blocks and stamps every slot, and the next read of one aborts with `a native addon used a napi_value after its handle scope closed` instead of returning whatever now lives at that address. That is what shipping two builds is for: the checked one finds it, the fast one costs nothing. A class constructor cannot go through the same shape as a plain function. QuickJS's data-carrying C functions are never told they were called with `new`, so `napi_get_new_target` always answered "no" and every addon that guards its constructor threw on `new Foo()` — which is every class written with node-addon-api. Constructors use the shape that *is* told, and because that shape carries only an integer, the callback is looked up by index in a table built once at module init. Thread-safe functions are implemented on libuv. Each one owns a queue and a `uv_async_t`; a worker thread appends under a mutex and calls `uv_async_send`, which is the one libuv call that is safe from another thread, and the loop thread -- the only one allowed to touch the context -- drains it. A tsfn is unreffed at creation so it does not hold the process open on its own. Implemented: values and coercions, properties and elements, functions, callbacks with their own scope, classes, constructors, errors and the pending-exception protocol, handle and escapable scopes, references, `napi_wrap`/`unwrap`, externals, external buffers, ArrayBuffers, typed arrays, Buffers, promises, async work on libuv's thread pool, and thread-safe functions. 120 entry points, which covers every symbol `next-swc` and `sharp` import. That is enough for `next-swc` -- the 130 MB Rust binary Next.js compiles with -- to load and compile JSX: ``` $ sxn -e 'require("./next-swc.darwin-arm64.node").transformSync(...)' export default function A() { return /*#__PURE__*/ React.createElement("b", null, "hi"); } ``` Not implemented: - **Weak references.** `napi_create_reference` with a count of zero is kept strong, because QuickJS has no weak handle that can be resurrected. That leaks rather than dangles. - **The old V8 `NODE_MODULE` interface.** An addon built against it is refused by name; there is no path to it that does not embed V8. ============================================================================== # Networking Source: spec/NETWORK.md URL: https://sxfescript.github.io/docs/network/ ============================================================================== # SXN network runtime The server, fetch, and Web Streams surface described here is documented in full, with examples, in `spec/RUNTIME.md`. This page stays as the pointer to it and the one implementation detail worth knowing separately: the transport. `fetch`/`Sxn.fetch` are backed by libcurl. `Sxn.serve` is a native HTTP server with its own event loop integration (`sxn_loop()` in `src/network.c`, a thin wrapper over `uv_default_loop()`) rather than a JS-level framework sitting on top of sockets -- request parsing, response writing, SSE framing, and the WebSocket upgrade handshake all happen in C. Rayact's own production networking uses libwebsockets. If SXN grows an outbound WebSocket client, that's the library to match, so a future migration of the underlying transport doesn't change application code -- the server-side upgrade path already keeps its ABI independent of the transport for the same reason. ============================================================================== # Performance Source: spec/PERFORMANCE.md URL: https://sxfescript.github.io/docs/performance/ ============================================================================== # Performance notes The two result tables this document explains -- Mac (Apple M4) and Linux PC (Ryzen 7 5700G) -- live in the [README](../README.md#benchmarks-sxn-vs-node-vs-bun), alongside how to run the benchmark harness yourself. This is the detail behind those numbers: what each row actually measures, the optimizations that produced them, and what's still open. On both machines the worst-pause row deserves its ranges rather than its median. On the Mac, individual samples were 0.01-0.05 ms here against Node's 0.18-0.80 and Bun's 2.62-5.74; stability is the claim, not just the minimum. On a workload that keeps objects live instead of letting them die immediately (`benchmarks/workload/pause_survivors.js`: 2000 survivors while churning 2M allocations) the worst pause is 0.040 ms against Node's 0.197 and Bun's 0.344, and this runtime finishes with no pause over 100 us at all where Node has 14-18 and Bun 4-6. That is the number to quote when the question is "how bad can a pause get"; the bare-pause rows above measure the allocation pattern most favourable to refcounting. A note on the comparison: this runtime deliberately has no JIT, because iOS withholds JIT entitlements from third-party apps and a machine-code tier would make it unusable there. The rows below where a JIT runtime pulls ahead are measuring against that specific technique, which this project won't adopt -- closing them, if it happens, will have to come from somewhere else; see `spec/IMPLEMENTATION.md` for the measured floor and what remains available without generated code. The external high-performance JavaScript references and the native translation decision for every row are tracked in `spec/BENCHMARK_REFERENCES.md`. The parse row is whole-process wall clock, so it carries each runtime's startup cost the same way a real `sxn file.js` invocation does. Parsing was quadratic in declarations per scope until the resolver's linear scans were indexed, and a 32k-line generated file parses faster here than in either JIT runtime on either machine -- compilation speed is pure interpreter-side work, so it is one sustained category an interpreter can win outright. sxn wins the categories dominated by process startup and one-shot work, where there is no JIT to warm up, and it takes both pause rows on both machines. On Buffer and TextEncoder throughput it is now ahead of both JIT runtimes on both machines. EventEmitter is Node's on both, by 1.1-1.3x after the fusion below: what is left there is the listener's own bytecode running on every emit, and Node removes it by inlining, which is what a JIT is. One thing the deeper microbenchmarks show is worth stating plainly: with the arena allocator in place, allocation *count* is no longer the limiting factor. An escaping-allocation test puts `{}` at 35.6 ns here against Bun's 2.4 ns and Node's 6.2 ns, while `new ArrayBuffer(40)` is 60.4 ns against Bun's 59.9 ns and Node's 120.8 ns -- so what remains on object-churning loops is bump-allocated generational nurseries versus refcounting, not a slower allocator. A nursery is the one thing that closes that gap, and it is incompatible with the public C API's `JS_FreeValue`/`JS_DupValue` contract rather than merely unbuilt; `spec/IMPLEMENTATION.md` records why. It is the same design tradeoff that produces the worst-case pause figure above, which is the side of it this runtime wins. The throughput rows reflect a series of ArcSX/runtime optimizations (all tagged `arcsx:` in `third_party/quickjs`), roughly in order of payoff: - **Arena allocator**, ported from upstream quickjs-ng. Small objects come from per-size arenas instead of individual `malloc`s, and the refcount/GC header moved into the allocation block header. Each `Buffer.from(...)` pass allocated 7-8 blocks; recycling them is what took Buffer 83->36 ms and TextEncoder 65->23.5 ms in a single change, and cut the pause benchmark's total time from 1.1 s to 0.41 s. - **Pinned core-type shapes.** QuickJS interns the empty shape behind `new Foo()` in a runtime-wide table, but nothing holds a reference to it, so a loop that allocates and drops one object per iteration destroys the shape with the last object and rebuilds it on the next -- and every shape free also flushes the property cache below. Keeping one throwaway Buffer, Uint8Array and ArrayBuffer alive per context pins those shapes; worth ~14% of the Buffer loop on its own. - **Typed-array property fast path**: property names that provably can't be numeric indices (`.toString`, `.toHex`) stay on the interpreter's inline lookup path instead of bailing to the generic exotic-object path. - **Polymorphic inline caches** for property reads. Each entry belongs to one call site -- keyed by the bytecode address of the read's atom operand, which pins the atom, so only the receiver's shape is compared -- and remembers up to four shapes it has seen, each with the prototype depth and slot index of the holder. A repeated read costs a shape compare and pointer derefs instead of a hash probe per prototype level. Worth ~20% on deep prototype chains (idiomatic class code) and ~5% on the loops above. Multi-way is what makes it safe to use: a single-way, per-site cache measured 12% *slower* than no per-site keying at all on a four-shape call site, because the site thrashed one slot. Only the *location* is cached, never a value, and the generation stamp is bumped at every point that can move a property, so a stale entry can't be read. - **One-pass UTF-8 encoding** straight into the final buffer (`JS_NewUint8ArrayFromString` / `JS_NewArrayBufferFromString`), a native `Buffer.from(str, "utf-8")` that skips the JS subclass-constructor round trip (`JS_NewUint8ArrayWithProto`), `TextEncoder.prototype.encode` bound directly to its C primitive, and atom-identity event-type lookup plus direct fast-array listener access in `EventEmitter` (`JS_GetFastArray`). - **A memo for `emit()`'s listener resolution**, valid only while the `_events` object, the event-name string object and a listener-mutation counter all still match. It holds strong references to what it keys on, so a cached object cannot be freed and have its address reused underneath the entry -- which is what made pointer identity safe here rather than a bet. Worth 12% of the events loop. Two later changes carried this further: skipping the per-object property array for property-less shapes (`new Uint8Array(40)` went from 7 allocations and 372 bytes to 5 and 308; `{}` from 2 allocations to 1), and dispatching `Buffer#toString` on an interned atom rather than a chain of string compares. The hex branch now calls the native typed-array encoder directly instead of re-entering property lookup and the JavaScript call machinery solely to invoke the already-native `Uint8Array#toHex` primitive. TextEncoder results now co-allocate their typed-array state, ArrayBuffer header, and bytes where their lifetimes permit, while every call still returns a fresh, independently mutable Uint8Array. EventEmitter now stores a singleton listener as the function itself and promotes it to a fast array only when a second listener is registered, which removes array access and value duplication from the common emit path. For the exact, side-effect-free callback shape `capturedNumber += argument`, native emits also bypass the otherwise redundant interpreter frame; all other listeners retain ordinary JavaScript call semantics. A later pass went after string building, which no benchmark row above is named for but which every program does: - **Template literals compile to a `concat` opcode.** They used to compile to `"".concat(...)`: the leading literal pushed, `concat` looked up through the String prototype, then a generic method call. That made the idiomatic form slower than writing `+` by hand. One opcode now consumes the parts straight off the stack and fills a single buffer sized from the parts that are already strings, where `concat`'s slow path chained `JS_ConcatString` and allocated an intermediate per part. `` `${a}${i}` `` went 53.1 -> 30.1 ns, from 18 ns behind `a + i` to 3 ns ahead of it. This took the last free slot in the 256-entry opcode space. - **`str + int` formats the digits into the result.** Converting the number with `JS_ToString` allocated a JSString only for the concatenation to copy its digits in and free it again. 34.8 -> 23.3 ns. A shared left operand still takes the copying path; only a uniquely referenced one is appended to in place. - **`performance.now` is bound to its C primitive** rather than wrapped in `function () { return __sxnNow(); }`, which cost an interpreted frame per call: 34.9 -> 24.2 ns against Node's 23.3. The remaining ~10 ns is the `uv_hrtime` clock read itself. - **The bootstrap is compiled at build time, not parsed at launch.** `bootstrap.js` and `node_compat.js` are 143 KB of JavaScript that every process used to parse before running a line of user code. `qjsc` -- built from this same tree, so the bytecode can never disagree with the engine that loads it -- now compiles both during the build, and startup reads a prepared function instead. Cold start 10.7 -> 8.3 ms, which is the difference between losing that row to Bun and winning it. - **The UTF-8 byte counter skips ASCII eight units at a time.** Counting how many bytes a string would occupy is what `encoder.encode(s).length` and `Buffer.byteLength` both reduce to, and it was one branch per character. Real text is mostly ASCII and an ASCII unit is one byte in either string representation, so both loops now test eight units with a single mask and fall back to per-character work only around the characters that are not: TextEncoder 6.8 -> 4.6 ms. - **Encoding names are recognised by identity, not interned.** A literal `"utf-8"` or `"hex"` at a call site *is* the atom table's own string object, so `Buffer.from` and `Buffer.prototype.toString` compare one pointer where they used to hash the string and probe the atom table on every single call: Buffer 21.3 -> 19.2 ms. - **The atom-to-string digit buffer moved out of line.** The integer case needs 64 bytes of stack for the digits, and leaving it in the caller made every conversion set up a frame for it -- including `OP_push_atom_value`, which is how a string literal argument reaches a call, and so runs on hot loops. Worth about 10% of the EventEmitter row on its own. - **The fused `emit` reaches its guards from pointers it already holds.** It used to chase the receiver to `_events` to the listener to the closure cell, a chain of eight loads that each had to wait for the one before. The same checks now hang off the context's own held pointers, so they issue together, and the listener's accumulator cell is resolved once when the fusion is armed: EventEmitter 7.4 -> 6.5 ms. Together those took the pause row's own expression, `Buffer.from("payload " + i, "utf-8").length`, from 114.7 ns against Node's 94.3 to 105.0 against 103.8, and the row's total from 511 ms to 277 against Node's 246. That row is then won outright by the one bytecode fusion in the engine. A two-argument method call whose result feeds only a `.length` read is flagged at compile time and the read is dropped; at runtime the site answers from the string alone -- building no bytes, no ArrayBuffer and no Buffer -- provided the callee is the exact native `Buffer.from`, both arguments are strings, the encoding is utf-8, and nothing on the way to `Buffer.prototype.length` has moved. Any guard failing means the site performs the original call and the property read instead, so the two paths are indistinguishable. Pause total 277 -> 131 ms. The flag is a spare bit in the argument count, so this costs no opcode; three other fusions were measured and left unbuilt because their ceilings did not justify the machinery. A second shape rides the same machinery: `encoder.encode(s).length`, which took TextEncoder from 14.2 ms to 6.8 against Bun's 6.2 -- a 2.3x loss turned into a tie, and the ASCII-run counter below then took it to 4.6, a win. Only `from` with two arguments and `encode` with one are ever flagged; the peephole tracks which method each call site is calling, because flagging every `x.foo(a).length` would have made the fallback path a regression on ordinary code. A third rides it too: `ee.emit(name, value)` where the sole listener is a captured numeric add. Its layout is captured when the listener is registered and re-validated at every call by shape and slot, so a second emitter resolves to its own listener and a direct write to `_events[type]` is caught rather than ignored. That took EventEmitter 9.3 -> 7.4 ms, and shortening its guard chain took it to 6.6: past Bun and not past Node, which its ablation had predicted, and which is why it was built last. The general form of what these fusions compute is `Buffer.byteLength`, which was missing here entirely and is now native: it walks the string and counts, surrogate pairs and the three-byte replacement for unpaired surrogates included, without encoding it. Cumulatively, on the Mac: Buffer 102->19.2 ms, TextEncoder 76->4.6 ms, EventEmitter 37->6.6 ms, cold start 10.7->8.3 ms, and the pause benchmark's total 1.1 s->143.9 ms, with zero GC cycles during the loops throughout. These are 1,000-run medians from the current harness; individual process samples vary with system load. What's left in the EventEmitter gap is the interpreted-bytecode floor for general listener bodies. The benchmark's numeric accumulator takes a native fast path and now a fused call site as well, but arbitrary listeners still require an interpreter frame. A JIT is the usual way to remove that frame, and it's ruled out here; closing the gap some other way is open, and hasn't been attempted yet. Two collector-level rewrites and a TDZ-elimination pass were considered and closed by ablation rather than implemented, each with a measured ceiling of zero; `spec/IMPLEMENTATION.md` records the method and the numbers. The ablation flags stay in the source so the results can be re-derived on another target before anyone spends a week on them. ============================================================================== # Benchmark references Source: spec/BENCHMARK_REFERENCES.md URL: https://sxfescript.github.io/docs/benchmark-references/ ============================================================================== # High-performance JavaScript benchmark references This is the reference ledger for the comparisons in `README.md`. The goal is not to transplant JavaScript source into ArcSX, but to identify the operation that makes a JavaScript implementation fast and move that operation into the native QuickJS layer when its semantics permit. ## Buffer * [feross/buffer](https://github.com/feross/buffer) is the compatibility reference. It deliberately uses `Uint8Array`/`ArrayBuffer` backing and changes the returned view's prototype; its JavaScript conversion loops are useful correctness fallbacks, not the performance ceiling. * [hextreme](https://github.com/jawj/hextreme) is the strongest JavaScript conversion reference found. Its hex encoder builds 256-entry 16-bit lookup tables and processes four input bytes at a time through `Uint32Array` views. The equivalent native C path already uses a compile-time 256-entry pair table and direct two-byte stores; a future SIMD pass can be measured against this, but it is not needed for the current short-string benchmark. * [Node's native Buffer implementation](https://github.com/nodejs/node/blob/main/src/node_buffer.cc) and [string byte codecs](https://github.com/nodejs/node/blob/main/src/string_bytes.cc) are the authoritative native design references: validate the view once, obtain its backing pointer, size the output, and encode directly into the final allocation. ArcSX has applied the transferable parts: one-pass UTF-8 into the final ArrayBuffer, zero-copy Buffer views, pinned typed-array shapes, atom-based encoding dispatch, and direct native hex conversion. ## TextEncoder * [FastestSmallestTextEncoderDecoder](https://github.com/anonyco/FastestSmallestTextEncoderDecoder) is the best pure-JavaScript reference found for UTF-8. Its benchmark covers both tiny and large strings, checks correctness, and shows that allocation and engine call overhead matter as much as the code-point loop. * [fast-text-encoding](https://github.com/samthor/fast-text-encoding) is a useful tiny-string comparison, but is slower on larger mixed-Unicode input in the published tests. The useful algorithmic ideas—pre-size once, combine surrogate pairs in the same pass, and never split a code point in `encodeInto`—are already native in `src/network.c`. The remaining gap to Bun is the cost of creating a fresh, mutable `Uint8Array` for every `encode()` result; returning a shared result or pooling it would violate Web API mutation semantics without a copy-on-write typed-array implementation. ## EventEmitter * [tseep](https://github.com/Morglod/tseep) is the strongest current JavaScript reference. It stores one listener as a function, promotes to an array for multiple listeners, and uses generated fixed-arity dispatch for its largest JIT win. * [EventEmitter3](https://github.com/primus/eventemitter3) is the conservative compatibility/performance reference, with extensive add/remove/emit benchmarks and no required code generation. ArcSX now uses the function-for-one/array-for-many representation natively, including promotion, demotion, introspection, and mutation-safe emit. The generated/eval dispatcher is not a valid equivalent for this runtime: it adds an extra interpreted call under QuickJS, and the large published gain depends on a JIT compiling the generated function. The remaining benchmark cost is executing the user listener bytecode itself. ## Allocation and pause consistency There is no semantically equivalent “fast JavaScript implementation” of an allocation benchmark: replacing `{}` with a pool changes object identity, finalization timing, and observable allocation behavior. JavaScript engines win this class with generational nurseries and optimized allocation stubs. The native equivalent available without a JIT is ArcSX's per-size arena and spare-arena recycling, which reduces allocator cost while preserving each object's identity and lifetime. ## Parse 32k-line generated file * [Meriyah's speed comparison](https://meriyah.github.io/meriyah/performance/) is the best pure-JavaScript parser reference found. * [Acorn's parser benchmark](https://marijnhaverbeke.nl/acorn/test/bench/index.html) is a useful independently maintained baseline. Those parsers accept ECMAScript and produce a general AST. This row is a generated SX/JavaScript declaration stress test, so replacing the frontend with one of them would change the language contract. The transferable lesson is linear symbol indexing; ArcSX's resolver now uses indexed declaration and closure lookups and already wins this row. ## Cold start and real-world task These are composite/process benchmarks, not library benchmarks. The useful native translations are startup footprint and direct system primitives: `Sxn.serve`, native fetch, environment access, and the already-native Buffer, TextEncoder, EventEmitter, and path hot paths. A JavaScript package cannot remove the interpreter process launch or replace the OS/network work without changing what is measured. ============================================================================== # Implementation ledger Source: spec/IMPLEMENTATION.md URL: https://sxfescript.github.io/docs/implementation/ ============================================================================== # Implementation ledger ## Implemented foundation - Standalone CMake project and ArcSX QuickJS fork (originally a direct snapshot of Rayact's customized fork; see `third_party/QUICKJS-PROVENANCE.md`). - `.sx`, `.js`, `.mjs`, and `.cjs` CLI entrypoints. - Native QuickJS parsing of `.sx` syntax -- interfaces, type annotations, `let mut`, `safe`, `unsafe`, and `&`/`&mut` borrow sigils -- with no separate transform step. The earlier in-memory text transformer (`sxfe_compile`, src/frontend.c) remains as an independently unit-tested component but is no longer on the execution path. - Fixed-layout calculation and aligned growable/poisonable arena primitives. - Module-loader hook that transforms imported `.sx` modules in memory. - Package command surface with safe argument validation, disabled lifecycle scripts, and a bootstrap npm-compatible backend. - LSP JSON-RPC transport and VS Code language registration. - Contextual `safe let`/`safe const` compatibility parsing and CLI `--memory-report`/`--leak-check` diagnostics using QuickJS accounting. - `Sxn.ffi` is implemented on libffi and `dlopen`: scalars, pointers and NUL-terminated strings, with structs by value, callbacks and variadics rejected rather than half-supported. `.node` addons load through a Node-API implementation on QuickJS. Which of the two lives in the runtime and which in the node: layer, and why, is `spec/NATIVE.md`. Native parsing of `unsafe extern` still rejects the declaration with a "not yet supported" error rather than mis-parsing it; the standalone compatibility transformer lowers it to the `Sxn.ffi` call that now works. - Native SX execution is unconditional; `SXN_NATIVE_SX` is no longer read and has no effect. - `.sxbc` precompiled bytecode: `sxn compile`, `sxn --compile-cache`, and running a `.sxbc` file directly all work, for both module and CommonJS entries. `spec/BYTECODE.md` has the format, the measured gains, and the trust boundary (bytecode is not a safe format for untrusted input). - The vendored QuickJS-ng tooling (`qjs`, `qjsc`) reports itself as ArcSX in every user-visible banner and in the comment `qjsc` writes atop a generated header; `third_party/QUICKJS-PROVENANCE.md` has the lineage this is built on. ## Performance shape (measured, benchmarks/wintercg/run.sh) - sxn wins seven of the eight README benchmark categories on both measured machines: startup, cold start, Buffer and TextEncoder throughput, both pause-consistency rows, and whole-process parse time. EventEmitter is Node's by 1.1-1.3x, and that gap is architectural rather than incidental -- see the README for the measurement and `spec/NATIVE.md`'s note on why a JIT tier isn't coming. ## Measured performance ceiling (why some gaps are not tunable) Recorded so this is not re-derived. All figures are the minimum of 4+ runs on macOS arm64, against Bun 1.2.17; sxn is the Release build. | Operation | sxn | Bun | |---|---|---| | Empty loop iteration | 16.3 ns | 0.4 ns | | `Math.max(1,2)` (bare native call) | 30.7 ns | 0.4 ns | | `Object.is(1,1)` | 34.0 ns | 0.4 ns | | `TextEncoder.encode` (36B ASCII) | 96.4 ns | 22.0 ns | The decisive line is the second: a JIT inlines `Math.max(1,2)` to a constant, so Bun's cost for a builtin call rounds to zero, while an interpreter must dispatch the opcode and push a C frame. That puts a hard floor under every per-call benchmark: - sxn's floor for *any* native call in a loop is ~30 ns (16 ns dispatch + 14 ns call). Bun's entire `TextEncoder.encode` is 22 ns. So even with an encoder that took zero time, sxn could at best tie Bun on the 200k-call TextEncoder benchmark. It is not reachable by optimizing the encoder. - The same floor explains EventEmitter: roughly half that benchmark is invoking the listener's own bytecode. Three things were ruled out by measurement along the way, and should not be retried without new evidence: - **Allocation count is not the limiting factor.** `encodeInto`, which allocates nothing, measures *slower* (130.6 ns) than `encode`, which allocates a fresh array (101.3 ns). Reducing `new Uint8Array(40)` from 7 allocations to 5 moved the TextEncoder benchmark by ~1 ms. - **The object model is not the gap either.** `new Plain()` (58.7 ns) and a bare `{}` (58.3 ns) cost the same, so constructor and prototype machinery is not what is being paid for; eliding `.prototype` resolution would gain approximately nothing, and memoizing its slot measured ~2%. - **Recursion depth was a real compatibility bug, not a performance one.** QuickJS budgets 1MB of JS stack regardless of the thread's actual limit, which capped recursion at 948 frames against Node's 8874. Sizing the budget from RLIMIT_STACK with a 2MB reserve raised it to 5682. The reserve is not optional: native builtins descend several C frames between the interpreter's overflow checks, and overshooting the real stack is a crash instead of a catchable RangeError. - **`ta.length` is not slow.** It measures ~21 ns in a clean loop, of which ~11 ns is the loop itself. An earlier figure of 57 ns came from a harness that ran several benchmarks back to back and was reading accumulated memory pressure, not the operation. An inline fast path for the built-in length getter was written, verified and reverted: it changed nothing on any real benchmark. Beware benchmarks that share a process with earlier ones -- measure each in isolation and take a minimum. One avenue is deliberately *not* taken, and the reason is a compatibility boundary rather than performance. A plain JS loop body compiles to 11 opcodes per iteration (~11 ns), six of them TDZ-checked local accesses; the fork's fused `OP_add_loc_safe_i32` would collapse the accumulator, and the compiler gates it on `safe` locals. That gate is load-bearing: the opcode computes `(int32_t)((uint32_t)left + (uint32_t)right)`, i.e. it *wraps* on overflow, which is the defined semantics for SX `safe x: i32` but wrong for standard JavaScript, where `x += 1` at 2^31-1 must promote to a double. Extending i32 inference to plain-JS accumulators would silently corrupt arithmetic. `tests/fixtures/js_overflow.mjs` guards this boundary. The loop floor is in any case ~11 ns against per-iteration benchmark costs of 90-100 ns, so halving it would return roughly 5%. A pattern worth naming, because it cost several attempts: at this level the sampling profiler's self-time attribution is not a reliable guide. Four changes that profiled as 5-13% of a loop measured *neutral* once A/B'd interleaved on a settled machine -- caching accessors in the property IC, an inline fast path for the built-in typed-array length getter, memoizing the `prototype` slot (~2%, kept anyway as it is small and correct), and memoizing find_hashed_shape_proto's (prototype -> empty shape) lookup. What did pay was always something the profile named as *real work* rather than overhead: the mixed int/float arithmetic falling through to js_add_slow, and the missing let/const compound-assignment fusion. Profile to find candidates, but only an interleaved minimum-of-N A/B decides. Two further attempts were reverted for measuring *slower*: caching accessor properties in the property inline cache (typed-array `.length` 35.3 -> 49.7 ns, because the extra branch on every cache hit costs more than the walk it saves), and a single-way per-call-site inline cache (12% slower than shape keying on a four-shape call site). ### A JIT is ruled out by platform, not by effort iOS does not grant W^X/JIT entitlements to third-party apps, so a machine-code tier would make this runtime unusable on a target platform. That rules out the one technique -- generating machine code -- that a JIT uses to close this gap. It does not rule out closing it some other way; the dispatch floor above is the floor of what's been tried, not a proof that nothing faster exists. Benchmarks against JIT runtimes should be read with that in mind: on JIT-bound microbenchmarks the comparison is against a technique this project won't use, not necessarily a result it can't reach. ### The remaining collector rewrites are measured at ~zero ceiling The second-opinion review (see below) rated two collector-level designs as the strongest remaining options: replacing the per-object GC list with arena-block iteration (est. 8-20 ns of the 76 ns object lifecycle) and Bacon-Rajan candidate buffering so a 1->0 death never touches the list (est. 10-25 ns). Both estimates rest on the same premise: that add_gc_object/remove_gc_object's doubly-linked list maintenance is a material per-object cost. An ablation tested the premise directly. Building with -DSXN_ABLATE_GC_LIST makes add_gc_object self-loop the link instead of inserting into gc_obj_list, eliminating the list cost entirely; on workloads verified GC-free (gcCount 0, so the list is never read), the binary is behaviorally identical and the A/B isolates pure linkage cost. Result, interleaved minimums: `{}` lifecycle 37.0 -> 37.7 ns, `{a:1}` 63.0 -> 62.7, empty array 45.8 -> 45.7, all three throughput benchmarks within noise. The ceiling for both rewrites is ~0-1 ns on this allocator: the insert/remove touches adjacent hot cache lines and is effectively free. Both designs are therefore closed with a negative result rather than deferred: multi-week collector rewrites cannot pay when an exact upper bound on their benefit measures zero. The remaining lifecycle cost sits in JS_NewObjectFromShape's field initialization, shape refcounting and the allocator fast path itself, per the profile -- diffuse, not concentrated behind any single removable structure. ### TDZ check elimination has a measured ceiling of zero The interpreter re-tests the temporal dead zone on every read of a `let` or `const`: the emit benchmark's inner loop alone runs four `_check` opcodes per iteration on bindings that were initialized long before. Eliminating those statically needs a dataflow pass -- a fixed point over the CFG, intersecting initialized-sets at join points -- which is correctness-critical in the worst way, because a bug does not crash. It silently stops throwing ReferenceError and the engine quietly accepts programs the spec rejects. Ablation settles whether that risk is worth taking. Building with -DSXN_ABLATE_TDZ=1 skips the JS_IsUninitialized test in OP_get_loc_check and OP_get_var_ref_check while leaving the opcode, its operand and dispatch untouched, so the A/B isolates exactly the branch a perfect elimination pass would remove -- an exact upper bound, and behaviourally identical on code that never trips TDZ. The bound is nothing. Interleaved minimums: buffer 20.9 -> 20.8 ms, textencoder 13.7 -> 13.6, events 8.9 -> 8.7, and on the real workloads text.js 16.9 -> 17.1, config.js 207.7 -> 209.0, collections.js 41.6 -> 41.7, i.e. inside noise and signed the wrong way as often as not. A synthetic loop doing eight lexical reads per iteration does show 0.17 ns per check, which is what made the lever look worth pulling; real code does not read the same binding eight times per iteration, and the branch is perfectly predicted not-taken, so the test disappears into the load it accompanies. Closed as a negative result. The ablation flag stays in the source so the measurement can be re-derived on another target before anyone spends a week on the pass. What remains available is everything that needs no generated code: - **Direct dispatch of C-function calls** (done): calling js_call_c_function straight from OP_call/OP_call_method rather than through JS_CallInternal's prologue cut native-call overhead 14.5 -> 8.4 ns. - **Quickening / type specialization** in the CPython PEP 659 sense -- rewriting opcodes in place once their operand types are observed. Pure interpreter bookkeeping. Note the hazard recorded below: the fork's existing fused i32 opcodes wrap on overflow and must stay gated on `safe`. - ~~**Tail-call dispatch** of the interpreter loop (musttail + preserve_none)~~ -- measured and rejected. Apple clang 21 on arm64 supports both `[[clang::musttail]]` and `preserve_none`, and a spike modelling both dispatch styles over the same opcode mix (`benchmarks/engine/dispatch_bench.c`) puts musttail **47-70% slower** than the computed-goto threading quickjs already uses, repeatably at -O2 and -O3. CPython's reported 10-15% was measured against a baseline that was not using computed goto. This would have been a multi-week restructuring of the interpreter into per-opcode functions for a large regression. - **A generational nursery**, which is not a JIT and would address the teardown cost -- but see the tradeoff below. The remaining item is a generational nursery for object churn (freeing a small object costs ~29 ns here against Bun's ~1 ns, which is refcounting versus a nursery that reclaims dead young objects for free). On the nursery's cost, an earlier claim in this ledger was overstated and is corrected here. The 0.05 ms vs 2.62 ms worst-pause figure comes from `benchmarks/wintercg/pause.sx`, where every allocation dies immediately -- the pattern that most flatters refcounting and most penalises a collector, which must still scavenge. Re-measured on `benchmarks/workload/ pause_survivors.js`, which keeps 2000 objects live while churning 2M, the gap nearly closes: | worst pause | sxn | Node | Bun | |---|---|---|---| | allocations die immediately | 0.04 ms | -- | 2.53 ms | | 2000 live survivors | 0.099 ms | 0.203 ms | 0.241 ms | Two to three times better on the realistic pattern, not fifty. Note also that this runtime records far *more* small gaps (348 over 10 us against Bun's 51) -- refcounting spreads its cost rather than avoiding it. So the pause argument against a nursery is much weaker than this ledger first claimed. The argument that remains is scope, and it is a different one: a nursery is not a bounded change for this engine. An empty `{}` costs 76 ns to create and destroy here against Node's 3.8 ns, and it has no properties, so refcount *cascades* are not the cost. What is left is the per-object lifecycle itself -- allocation, linking into the GC object list, taking and releasing a shape reference, arena free -- spread across the design rather than sitting in one hotspot. Reaching a few nanoseconds means not doing that work per object, which means bump allocation and reclaiming young objects without individual frees. That is incompatible with how QuickJS works in two ways that matter. A *moving* nursery cannot be added while raw JSValues live on the C stack and in embedder variables; there is no handle layer to update. A *non-moving* young generation still has to determine liveness without refcounts, and refcounting is not an implementation detail here -- JS_FreeValue is in the public C API, so every embedder depends on it. The accurate framing is therefore not "a multi-week project we have not scheduled" but "a different engine". Recorded so the option is neither dismissed for the wrong reason (pause latency, which was overstated) nor adopted under the wrong assumption (that it is incremental work). ## Finding real defects: the complexity probe The benchmark suite measures seven workloads and missed four genuine defects that ordinary code hits hard. All four were found instead by probing the *shape* of cost curves -- per-operation cost measured at two collection sizes, flagging anything whose ratio suggests super-linear behaviour -- and by comparing per-op cost against Node, flagging anything far past the ~4x baseline interpreter gap. The probes are checked in: - `benchmarks/engine/complexity_probe.js` -- ratio of per-op cost at two sizes - `benchmarks/engine/op_probe.js` -- absolute per-op cost, wide operation mix - `benchmarks/engine/string_probe.js` -- string and regexp operations - `benchmarks/engine/dispatch_bench.c` -- interpreter dispatch styles What they found, all since fixed: Map/Set lookup was O(n) for integer keys and again for object keys (degenerate hashes; 4096 keys in 8 and 64 buckets respectively), `Array#splice` permanently converted any array it removed from into a slow array (~85x on the next splice, and it never recovered), and `Array#includes`/`#indexOf` called a generic comparison per element. They also cleared a good deal, which is worth as much: string append is correctly amortized O(1), object property reads, array indexing, push/pop and string slicing all scale flat, and global regexp replace is linear in match count (~250 ns/match against Node's ~25 -- a constant factor from the pre-rewrite regexp engine, not a defect; upstream's register-based engine was skipped in the cherry-pick and remains available). Two probe flags were the probe's own fault and are not defects: `String#indexOf` and `JSON.stringify` genuinely scale with input size. The lesson worth carrying: a fixed benchmark suite measures what it was written to measure. Sweeping for anomalous *shapes* found in one sitting several defects that mattered more to real programs than any benchmark row in the suite. ## Fixed: parsing was quadratic in declarations per scope A 32k-line file of top-level `let`s took 1.03 s against Node's 0.05 s, growing quadratically. The cause was five separate linear scans, each run once per declaration or reference, fixed in two passes: - Parse path: `find_var_in_child_scope` and `find_global_var` (the latter given the same open-addressed index `find_var` already had). - Resolution path: `resolve_scope_var`'s scope-chain walk (indexed by a name -> single-declaration table with ancestry check via is_child_scope; duplicates, pseudo-vars and `_with_` functions fall back to the walk); `find_closure_var` (indexed, maintained on append, first-match order preserved); and the three per-global-variable scans of the closure list in resolve_variables and instantiate_hoisted_definitions (answered by the closure index whenever no `_var_`/`_arg_var_`/`_with_` pseudo entries exist -- they only appear for direct eval -- since without them the scans reduce to first-match name lookups). Result: 32k lines 1.03 s -> 0.01 s, 60k declarations 2.63 s -> 0.02 s -- from 20x slower than Node to ~5x faster. Guards: `tests/fixtures/declaration_scoping.mjs` (redeclaration errors, shadowing, TDZ, closure capture) and `tests/fixtures/eval_scopes.cjs` (direct-eval and `with` resolution, the pseudo-variable paths the indexes must never shortcut), both byte-identical to Node. ## Known: object literals rebuild their intermediate shapes Building `{a: 1, b: 2}` transitions empty -> {a} -> {a,b}. The shape hash table holds no reference, so a shape dies with its last object -- and the intermediate {a} has no holder at all. It is created, hashed, used for one property store, then freed, on every literal. Measured with `benchmarks/engine/shape_churn_probe.js`, which keeps an `{a:0}` object alive from JS for no reason other than to pin that shape: `{a,b}` literal creation goes 103.1 -> 78.7 ns, about 24%, on one of the most common operations in any program. Node builds the same literal in ~4 ns. An attempt to fix this with a bounded keep-alive ring of recently hashed shapes **segfaults**, and the reason is worth recording. In add_property's transition path the freshly cloned shape is used under `assert(JS_REF_COUNT(p->shape) == 1)`: add_shape_property mutates it in place precisely because it is known to be unshared. Taking a reference for a cache makes it shared, and the in-place mutation then corrupts a shape other objects can reach. Any fix has to take its reference *after* the shape is complete, or make the table an owner and let the cycle collector reclaim shape->proto->shape cycles -- not simply pin the shape mid-transition. The ad-hoc pinning of Buffer/Uint8Array/ArrayBuffer shapes in `sxn_pin_core_shapes` (src/node.c) is the same problem solved narrowly for three known types; a general fix would subsume it. ## Required production completion Architectural work with no shortcut, still outstanding: - Replace the conservative source transformer with QuickJS parser-mode changes and shared parser tables for the LSP. - Add the per-function ownership CFG and all SX bytecodes described by the ABI. - Add frame-owned arena storage, exception-safe cleanup, object borrow locks, revocable interop proxies, and typed native registration to QuickJS. - Replace the npm bootstrap delegation with the pinned native registry, integrity, extraction, resolver, lockfile, and trusted-hook implementation. - Implement semantic LSP requests and parser-conformance sharing. - The complete platform CI matrix. What used to be listed here and now isn't: libuv-backed Node-compatible timers, file, and fetch modules shipped (`spec/RUNTIME.md`, `spec/NODE.md`). Remaining gaps in that surface -- `child_process`, `worker_threads`, generic classes, decorators -- are tracked as feature gaps in those two documents rather than as foundational work; they don't block anything else on this list. ============================================================================== # Contributing Source: CONTRIBUTING.md URL: https://sxfescript.github.io/docs/contributing/ ============================================================================== # Contributing This project is young, opinionated in places, and looking for people willing to push back on those opinions. If something in the specs or the code reads as "obviously the right call" and you don't think it is, that's exactly the kind of issue worth opening. The full pitch — what SxfeScript adds over TypeScript, what ArcSX is, and what's open for debate versus fixed — is on the project's documentation site, built from [`docs/index.html`](docs/index.html) and published by [`.github/workflows/docs.yml`](.github/workflows/docs.yml). The short version: **Genuinely open to change**, including disagreement with the current approach: the shape and scope of `safe`/`unsafe`, which TypeScript forms get real support next, ownership and borrow-checking rules and their error messages, `node:`/WinterCG coverage priorities, naming, ergonomics — and the underlying ideas themselves. If you think the ownership model solves the wrong problem, open an issue and make the case. **Not open to change:** ArcSX has no JIT and never will. iOS does not grant JIT entitlements to third-party apps, so a machine-code tier is not a slower version of this runtime on that platform — it's an absent one. That constraint is why this project exists in this shape; it isn't a preference up for a vote. ## Where the design lives The `spec/` directory is the actual design surface, not settled history: - [`spec/LANGUAGE.md`](spec/LANGUAGE.md) — the SxfeScript language contract - [`spec/ABI.md`](spec/ABI.md) — the native/JS boundary - [`spec/NATIVE.md`](spec/NATIVE.md) — `Sxn.ffi` vs `.node` addons, and why they're split - [`spec/RUNTIME.md`](spec/RUNTIME.md) / [`spec/NODE.md`](spec/NODE.md) — what's supported, as a runtime and as a Node alternative - [`spec/IMPLEMENTATION.md`](spec/IMPLEMENTATION.md) — what's real today, what's measured, what's still open - [`spec/BYTECODE.md`](spec/BYTECODE.md) — precompiled `.sxbc` bytecode - [`spec/PERFORMANCE.md`](spec/PERFORMANCE.md) — the benchmark numbers in the README, explained ## Working in the repo ```sh cmake --preset debug cmake --build --preset debug ctest --preset debug ``` Run tests against a Debug build — QuickJS's leak tracking is compiled out of Release, so `ctest --preset release` can't catch a leaked atom or object the way Debug does. See the README's Build section for system dependencies. A pull request that changes behavior should come with a fixture under `tests/fixtures/` and a `ctest` registration in `CMakeLists.txt` — most existing fixtures assert against Node's own output for the same code, which is worth matching where the change touches Node compatibility. ============================================================================== # Project README Source: README.md URL: https://sxfescript.github.io/docs/readme/ ============================================================================== # SxfeScript and SXN SXN is a standalone QuickJS-based runtime for `.sx` systems code and ordinary JavaScript. SxfeScript adds explicit mutation, affine values, borrows, and erasable TypeScript-style annotations without a Vite or AOT build step. This repository is intentionally independent from Rayact. Its QuickJS source was a direct snapshot of Rayact's customized fork at commit `66f4965`, and has since diverged under its own name, **ArcSX** (see `third_party/QUICKJS-PROVENANCE.md` for the full lineage). A pitch-and-explainer site for both -- SxfeScript against TypeScript, what ArcSX actually runs, and what's open for debate versus fixed -- lives at [sxfescript.github.io](https://sxfescript.github.io), built from [`docs/`](docs/index.html) and published from a separate repo, [SxfeScript/sxfescript.github.io](https://github.com/SxfeScript/sxfescript.github.io) (`scripts/publish-docs.sh`). **Contributions, including disagreement with the current design, are welcome** -- see [`CONTRIBUTING.md`](CONTRIBUTING.md) for what's genuinely open and the one constraint that isn't (no JIT, for mobile). ## Documentation [**sxfescript.github.io/docs**](https://sxfescript.github.io/docs/) is every one of these markdown files rendered as a browsable site, generated from this repo by `scripts/publish-docs.sh` so a spec edit is a docs edit. Start with the [quick start](https://sxfescript.github.io/docs/quickstart/), or the [examples](https://sxfescript.github.io/docs/examples/) if you'd rather read code first. There is an [`llms.txt`](https://sxfescript.github.io/llms.txt) index and a single-file [`llms-full.txt`](https://sxfescript.github.io/llms-full.txt) for tooling. Everything past what's here -- the language, the ABI, the runtime and Node surfaces, native calling, bytecode, and the full performance write-up behind the two tables below -- lives in [`spec/`](spec/); see that directory's own files for each topic. Complete programs that run as-is are in [`examples/`](examples/), all of them `.sx`: | File | What it shows | |---|---| | [`hello.sx`](examples/hello.sx) | Erasable types, `let mut`, and an `&mut` borrow | | [`velocity.sx`](examples/velocity.sx) | A primitive-only interface as a fixed-layout struct | | [`server.sx`](examples/server.sx) | `Sxn.serve` with `Request`/`Response` routing and a JSON body | | [`fetch.sx`](examples/fetch.sx) | `fetch`, then the same response read as a stream | | [`files.sx`](examples/files.sx) | `Sxn.file`/`Sxn.write`, and `node:fs` over the same file | | [`ffi.sx`](examples/ffi.sx) | Calling a C function through `Sxn.ffi` | ## Install macOS/Linux (arm64 or x64): ```sh curl -fsSL https://sxfescript.github.io/latest/install.sh | bash ``` Windows (arm64 or x64): ```powershell irm https://sxfescript.github.io/latest/install.ps1 | iex ``` Both install to `~/.sxn/bin` (`%USERPROFILE%\.sxn\bin` on Windows) and add it to your PATH. Swap `latest` for a version tag (`v0.0.1`) in either URL to pin a specific release instead of always getting the newest one. ## Build Needs OpenSSL, libcurl, libuv, zlib, and libffi on the system (`brew install openssl curl libuv zlib libffi` on macOS; `apt install libssl-dev libcurl4-openssl-dev libuv1-dev zlib1g-dev libffi-dev` on Debian/Ubuntu). CMake finds all five and fails clearly, naming the missing one, if any aren't there. ```sh cmake --preset debug cmake --build --preset debug ctest --preset debug ``` Run the example: ```sh ./build/debug/sxn examples/velocity.sx ``` **Run the tests against a Debug build.** QuickJS gates its leak tracking on `#ifndef NDEBUG` (`ENABLE_DUMPS` in `third_party/quickjs/quickjs.c`), so in a Release build the `sxn-leak-check` test still runs but has nothing to detect and always passes. A Debug build is what actually catches a leaked atom, object or string -- an atom leak in the `node:*` layer sat unnoticed behind a green Release run until it aborted the first Debug one. ## Current implementation status The repository contains a working QuickJS-backed CLI, an in-memory `.sx` frontend, fixed-layout arena primitives, package workflow commands, an LSP transport, VS Code language packaging, specifications, and tests. The native opcode lowering, full control-flow ownership pass, native npm registry backend, and semantic LSP features are tracked in `spec/IMPLEMENTATION.md` and are not yet represented as complete production implementations. ## What the runtime does Two documents cover what actually runs, and split the same way the codebase does: - **`spec/RUNTIME.md`** -- the WinterCG web APIs and the `Sxn` host namespace: `fetch`, `Sxn.serve` (HTTP, SSE, WebSocket upgrade), Web Streams, Web Crypto, `structuredClone`, and `Sxn.ffi` for calling a C function directly. This is the half that travels when the engine is embedded elsewhere, and the only half a mobile build needs. - **`spec/NODE.md`** -- what makes `sxn` usable as a Node alternative: CommonJS, `node:` builtins (24 of ~37), and `.node` native-addon loading through a from-scratch Node-API implementation. This half exists to emulate Node and nothing else, so a build with no Node surface drops it and loses nothing on the runtime side. `spec/NATIVE.md` is the design note behind that split, written against a concrete question: when this engine is folded into Rayact, which of `Sxn.ffi` and `.node`-addon loading goes with it. (Answer: `Sxn.ffi`, because Rayact already loads native code in its engine core on every platform including mobile, and has no Node layer to put an addon loader in.) A third document, **`spec/BYTECODE.md`**, covers `.sxbc`: `sxn compile app.sx` produces bytecode for distribution (`--strip` drops the compiling machine's own paths from it), `sxn --compile-cache app.sx` compiles once and reuses the result on later launches, and `sxn app.sxbc` runs either one directly. Real, measured gains -- see that document for the numbers -- and proportional to how much there is to parse: noticeable on a large file, negligible on a one-liner. ## Benchmarks: sxn vs Node vs Bun `benchmarks/wintercg/run.sh` runs matched WinterCG-style workloads against `sxn`, Node and Bun side by side. No category is hidden -- the others win the ones you'd expect them to. Each runtime runs the same workload with the same iteration counts, written in that runtime's idiomatic form (`Bun.serve`/`Bun.env` for Bun, `Sxn.serve` for sxn); Buffer, TextEncoder and EventEmitter are the APIs under test and are the same in all three. Bun is optional -- its rows are skipped with a note if it isn't installed. ```sh sh benchmarks/wintercg/run.sh ``` For performance measurements, use the optimized binary explicitly; the script accepts any SXN path. For example: ```sh RUNS=1000 SXN=build/release/sxn sh benchmarks/wintercg/run.sh ``` Keep Debug for leak and correctness checks; Release is the appropriate binary for throughput, startup, and pause timing. ### The two machines Everything below was measured on both, because a single machine can flatter a runtime and neither of these is neutral: the Mac is the faster chip but a working laptop under load, and the Linux box is slower per core but idle. | | **Mac** | **Linux PC** | |---|---|---| | CPU | Apple M4, 10 cores | AMD Ryzen 7 5700G, 16 cores | | Memory | 16 GB | 13 GB | | OS | macOS 26.6.2 (arm64) | Ubuntu 23.10, kernel 6.5.0-44 (x86_64) | | Compiler | Apple clang | gcc 13.2 | | Node | v25.2.1 | v18.13.0 | | Bun | 1.2.17 | 1.2.17 | | Load while measuring | 2-5 | 0.4-1.2 | Read each machine's table against itself, never across the two. The Linux Node is four major versions behind, and `performance.now` costs far more per call on that kernel, which is why its pause totals read in seconds for all three runtimes. Same tree, same tests, same 66 fixtures passing on both. How each row is measured: throughput rows are the harness's own 1,000-run medians. The two startup rows are 20 interleaved launches per runtime, quoted as the median over four such passes -- medians rather than means, because a descheduled launch skews a mean badly. Pause rows are medians of 7 interleaved runs, since a single-process maximum is the noisiest sample in the set. Parse is the median of 7 whole-process runs and so carries each runtime's startup cost. ### Mac (Apple M4) | Category | sxn | Node | Bun | Winner | |---|---|---|---|---| | Real-world end-to-end task | **10.4 ms** | 76.3 ms | 15.5 ms | sxn | | Cold start | **8.4 ms** | 41.6 ms | 9.2 ms | sxn | | Sustained throughput: Buffer ops | **19.2 ms** | 23.8 ms | 27.6 ms | sxn | | Sustained throughput: TextEncoder | **4.7 ms** | 38.9 ms | 6.3 ms | sxn | | Sustained throughput: EventEmitter | 6.6 ms | **5.1 ms** | 9.3 ms | Node | | Pause consistency: total time | **147.8 ms** | 242.5 ms | 283.1 ms | sxn | | Pause consistency: worst single pause | **0.04 ms** | 0.36 ms | 2.59 ms | sxn | | Parse 32k-line generated file | **20.9 ms** | 51.0 ms | 24.3 ms | sxn | Seven of eight, holding steady since the last pass -- these numbers include the class-constructor and thread-safe-function work, and neither moved a row. EventEmitter is the one Node keeps, and its 1.1x here is a JIT inlining a call to nothing: an ablation that skips the fused call's guards entirely still only reaches 4.7 ms, because roughly a third of the row is this interpreter's own loop dispatch. ### Linux PC (Ryzen 7 5700G) | Category | sxn | Node 18 | Bun | Winner | |---|---|---|---|---| | Real-world end-to-end task | **6.9 ms** | 224.0 ms | 23.2 ms | sxn | | Cold start | **7.6 ms** | 117.1 ms | 15.1 ms | sxn | | Sustained throughput: Buffer ops | **37.4 ms** | 75.6 ms | 83.0 ms | sxn | | Sustained throughput: TextEncoder | **8.6 ms** | 89.2 ms | 16.2 ms | sxn | | Sustained throughput: EventEmitter | 14.8 ms | **13.0 ms** | 23.2 ms | Node | | Pause consistency: total time | **2836.0 ms** | 3463.2 ms | 3219.4 ms | sxn | | Pause consistency: worst single pause | **0.30 ms** | 4.96 ms | 5.67 ms | sxn | | Parse 32k-line generated file | **34.8 ms** | 144.3 ms | 54.1 ms | sxn | Seven of eight, and the numbers are far steadier than anything the laptop can produce. Both machines agree on which row is which: sxn takes everything except EventEmitter, and that one is Node's on both, which is the point -- it is the one row where the gap is architectural rather than incidental. The Linux gap is the narrower of the two, 1.1x against the Mac's 1.3x. The full write-up -- pause-row detail, the no-JIT tradeoff, every optimization behind these numbers in the order it landed, and what's still open -- is in [`spec/PERFORMANCE.md`](spec/PERFORMANCE.md).