SxfeScript · runs on ArcSX

Ownership and borrows, on top of ordinary JavaScript.

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

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;
}
          

What it looks like

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"}]

More of them, each with its output, on the examples page. The quick start goes from an empty directory to a running server.

What SxfeScript actually adds

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.

Ownership & borrows

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.

The safe / unsafe boundary

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.

Erasable TypeScript annotations

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.

Fixed-layout structs

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.

SxfeScript vs. TypeScript

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.

QuestionTypeScriptSxfeScript
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: the runtime underneath

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 onsxnNodeBunIn plain terms
Starting a new process 8.4 ms41.6 ms9.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 ms23.8 ms27.6 ms Faster than both, at the operation almost every server-side script does constantly
Encoding text (TextEncoder) 4.7 ms38.9 ms6.3 ms 8x faster than Node, ahead of Bun too
Worst single GC-style pause 0.04 ms0.36 ms2.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 ms5.1 ms9.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.

WinterCG surface 45 / 55

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.

Node compatibility 24 / ~37

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.

Calling native code

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.

Precompiled bytecode

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.

Contributing

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.

What's genuinely open

  • The shape and scope of safe/unsafe
  • Which TypeScript forms get real support next
  • Ownership and borrow-checking rules and their error messages
  • node: and WinterCG coverage priorities
  • Naming, ergonomics, anything that reads as a mistake

What isn't

  • Adding a JIT, or anything else that generates machine code at runtime. iOS won't grant that entitlement to a third-party app, and running there is the point

Everything 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: