SxfeScript · runs on ArcSX
SxfeScript is JavaScript with an explicit safe and unsafe boundary, affine values, and lexical borrows, parsed natively, with no build step, and run by ArcSX, a QuickJS-based runtime built to run the same on a phone as it does on a server.
curl -fsSL https://sxfescript.github.io/latest/install.sh | bash
irm https://sxfescript.github.io/latest/install.ps1 | iex
arm64 or x64, either way. Every release also has a
plain .zip/.tar.gz on
Releases.
TypeScript: erased at build time
function withdraw(acct: Account, n: number) {
// nothing here is true at runtime.
// acct could be null. n could be NaN.
// tsc already stopped watching.
acct.balance -= n;
}
SxfeScript: checked, then run
function withdraw(&mut acct: Account, n: i32) {
// acct is a live, exclusive borrow --
// checked at parse time, not hoped for.
acct.balance -= n;
}
Each of these is a real .sx file in
examples/, and the
output under it is what that file actually prints. Nothing here needs a
config file, a bundler, or a step before sxn runs it.
examples/server.sx
interface Note {
id: number;
text: string;
}
const notes: Map<number, string> = new Map([[1, "the first note"]]);
let mut nextId: number = 2;
const server = Sxn.serve({ port: 0 }, async (req: Request): Promise<Response> => {
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();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"}]
examples/hello.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 }));
// `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}`);sxn examples/hello.sx
counter: 2
examples/fetch.sx
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`);sxn examples/fetch.sx
200 text/html
559 bytes
1 chunk(s), 559 characters
examples/ffi.sx
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));sxn examples/ffi.sx
pow(2, 10) = 1024
sqrt(144) = 12
More of them, each with its output, on the examples page. The quick start goes from an empty directory to a running server.
Every ordinary JavaScript value keeps its ordinary semantics. SxfeScript layers four
things on top, and all four are optional. A file that uses none of them is a
normal .js file that happens to have a .sx extension.
let mut creates a mutable owner, let an immutable one.
&value borrows it shared; &mut value borrows it
exclusively and requires a mutable owner. A borrow can't be returned, stored somewhere
longer-lived, or carried across an await. Each rule is checked before the
code runs, not documented and hoped for.
safe let marks a binding as type-stable: the object shape it points to
rejects property addition, deletion, and incompatible writes. Reaching outside that
(a raw pointer, an FFI call) means writing unsafe and meaning it,
the same contract Rust makes explicit.
Type aliases, interfaces, declare, generics on functions, optional
parameters, as/satisfies, union types: all of it parsed and
stripped natively, no tsc, no bundler, no separate compile step.
A primitive-only interface (i32, f32, f64,
bool) defines an affine struct with declared field order and natural
alignment: the same layout on every supported target, for code that has to cross
into native memory.
TypeScript's types exist for the compiler and your editor, and stop existing the moment
tsc finishes. A null you were promised isn't there can still be null.
SxfeScript's safe qualifier is the same idea pushed one layer
deeper: the guarantee is checked before the code runs, and object shapes that would
violate it are rejected rather than merely un-typed.
| Question | TypeScript | SxfeScript |
|---|---|---|
| What happens to the types at runtime? | Erased entirely: a type is a compile-time fiction | safe bindings keep a runtime descriptor; incompatible writes are rejected, not silently accepted |
| Do you need a build step to run a file? | tsc, or a bundler standing in for it |
No. sxn app.ts strips and runs it directly |
| Aliasing and mutation | Not modeled at all | Explicit: an owner, a shared borrow, or an exclusive borrow, one at a time, checked |
| Calling into native code | Whatever your runtime's FFI story is. TypeScript has no opinion | An explicit unsafe extern boundary; everywhere else stays safe by default |
| Can a "safe" line still corrupt memory? | Not applicable. There's no runtime safety claim to keep | Not while it stays safe; unsafe is the one place that trust is spent explicitly |
TypeScript documents intent for humans and tooling. SxfeScript's safe subset makes a narrower, different claim: a runtime contract, smaller in scope than TypeScript's type system today (see the honest gap list in spec/IMPLEMENTATION.md), and still growing, but real where it applies rather than a promise the runtime never checks.
ArcSX is a QuickJS-ng fork, built as sxn. It runs .sx,
.ts, and ordinary .js/.mjs/.cjs
directly, with two compatibility layers built on top of the same engine core: one for the
browser-shaped WinterCG web APIs, one for Node.
What that's actually good for, measured against Node 25 and Bun 1.2 on the same Apple M4 (full methodology, a second machine, and the interpreter-level work behind these numbers are in the README):
| What you're waiting on | sxn | Node | Bun | In plain terms |
|---|---|---|---|---|
| Starting a new process | 8.4 ms | 41.6 ms | 9.2 ms | About 5x faster than Node to start, and a hair ahead of Bun. Matters for a CLI tool or a serverless cold start |
Processing binary data (Buffer) |
19.2 ms | 23.8 ms | 27.6 ms | Faster than both, at the operation almost every server-side script does constantly |
Encoding text (TextEncoder) |
4.7 ms | 38.9 ms | 6.3 ms | 8x faster than Node, ahead of Bun too |
| Worst single GC-style pause | 0.04 ms | 0.36 ms | 2.59 ms | No JIT means no warm-up stalls. The most predictable of the three, which is what a real-time or low-latency workload actually needs |
| Firing many event listeners | 6.6 ms | 5.1 ms | 9.3 ms | The one row Node wins, narrowly. Its JIT inlines the hot path in a way an interpreter structurally can't |
All five rows, every runtime, same machine, same workload, run side by side by
benchmarks/wintercg/run.sh in the repo. Nothing here is cherry-picked
or estimated.
fetch, Sxn.serve (HTTP, SSE, WebSocket upgrade), Web
Streams, Web Crypto, structuredClone. This half travels wherever the
engine is embedded. It's not tied to Node emulation.
CommonJS, node: builtins, and .node native addons through
a from-scratch Node-API implementation, real enough that next-swc,
the Rust binary Next.js compiles JSX with, loads and runs under it.
Sxn.ffi calls a C function through libffi. It's an engine capability,
not a Node one. The design reasoning for that split, and what it means for
mobile, is in spec/NATIVE.md.
sxn compile app.js writes a .sxbc file that skips
parsing entirely on later runs, measured 12% faster on a one-line script, 37%
on a 618 KB generated file. --compile-cache does the same thing
automatically, on every launch. spec/BYTECODE.md.
Every number above is measured, not estimated. The full benchmark methodology, both test machines' specs, and the interpreter-level optimizations behind them are in the project README.
This project is young, opinionated in places, and actively looking for people who'll push back on those opinions. If something here reads as "obviously the right call" and you don't think it is, that's exactly the kind of issue worth opening.
safe/unsafenode: and WinterCG coverage prioritiesEverything above is a real conversation. That one line is a constraint the project is built around, not a preference.
Ideological disagreement is welcome too. If you think the ownership model is solving
the wrong problem, or that the safe/unsafe split should work differently, open an issue
and make the case. The specs in spec/ are the actual design surface of this
project, not settled history: