JavaScript and PHP Back Ends#

Status: planned — milestone 9 (ROADMAP.md); no back end exists. This record keeps what was decided on 2026-09-18 and 2026-09-19, and since 2026-10-23 the representation of a 64-bit Int in JavaScript (section 2a) and the browser's capability table (section 2b), with their research. The research into PHP is still to come (section 3).

TorbScript compiles to JavaScript and to PHP as well as to C and to the bytecode of the VM. JavaScript, so that the front end of an interactive application is written in the same language as its server. PHP, so that a program runs on a classic shared web host where nothing but PHP is available. Packages that JavaScript and PHP code can consume are a goal where they come cheaply, not a condition.

1. What the architecture already gives#

  • Everything up to the typed IR is shared, and a back end turns the IR into text (ARCHITECTURE.md, "Back Ends"). C is the first back end and the VM the second; these are the third and the fourth.
  • The IR stays free of C's assumptions: layouts and niches are hints a back end may ignore.
  • Natives stay few, because every back end has to provide every one of them. The kernel of natives (ROADMAP.md, "The kernel of natives") is done before this milestone, so a new back end starts from a short list of IR intrinsics and the rest of std is TorbScript it compiles like any other code.

2. What is decided#

  • The language's semantics, not the host's. The integer types keep their fixed widths and panic on overflow exactly as in C, so a JavaScript Int is an exact 64-bit integer however it is represented (section 2a). A type that may contain a Close object stays reference counted even where the host has a garbage collector, because the moment its close() runs is part of the program's meaning (DESTRUCTORS.md section 2a). Everything else can leave memory to the host's collector and needs only copy on write for var paths.
  • Tasks in JavaScript are promises on the event loop of the host.
  • Tasks in PHP run on an event loop that is chosen when the program starts: Fibers where PHP has them (8.1 and later), ext-uv or ext-ev where one is installed, plain stream_select otherwise. The goal is "copy the files onto the web host and it runs", with no extension required. Without Fibers a task is compiled into a state machine, which the C back end does already, so older versions of PHP are possible; how far back is decided by the research. Composer libraries may be required, but compatibility with a classic web host comes first.
  • A capability table per target. Each capability package is, per target, a real implementation, a simulation, or a compile error at the import: std/fs in the browser over the origin private file system, std/os's environment empty in the browser, std/process a compile error there. Section 2b is the browser's column, row by row. RELEASE.md section 6 applies the same rule to the playground's WebAssembly target.
  • Packages for the host's ecosystem - npm with .d.ts declarations, Composer with PSR-4 autoloading - are generated for public interfaces that do not depend on generic tricks. Value semantics across the boundary mean a copy on the way in and on the way out.

2a. An exact 64-bit integer in JavaScript (decided 2026-10-23)#

An Int is a JavaScript number while it lies in -(2^53 - 1) to 2^53 - 1, and a bigint outside of that range, and never a bigint inside it. The form is canonical: a value has exactly one representation, so === is == of Int, < compares a number with a bigint exactly (JavaScript does this natively), a Map key has one form, and no helper is needed for a comparison. UInt64 is the same with the range 0 to 2^64 - 1. Int8 to Int32 and UInt8 to UInt32 are always a number: their arithmetic is exact in a double and the range is checked after the operation (a product that a double has to round is far outside the type anyway).

The rules of the generated code, for Int and its siblings of 64 bits:

  • +, - and * on two numbers run on doubles and are kept when the result is still within 2^53 - 1. A double operation whose exact result is larger rounds to at least 2^53, so the range test cannot miss an inexact result. Every other case (an operand that is a bigint, or a result outside the range) runs on bigint, panics if the result is outside the 64-bit range, and goes back to a number when it fits 2^53 - 1. A result of -0 is turned into 0.
  • The panic is the same panic. An overflow (arithmetic overflow in `+` ), a zero divisor and the smallest value divided by -1 panic as in C and in the VM, and addedChecked and its twins answer None exactly where the operator panics. The wrapping UInt64 operations (addedWrapping, multipliedWrapping) are BigInt.asUintN(64, ...) on the slow path.
  • / and % of two numbers are a % b and (a - a % b) / b, both exact for numbers that fit 2^53 - 1.
  • The bit operators &, | and ^ run on 32-bit operands in place when both operands fit 32 bits and on bigint with BigInt.asIntN(64, ...) otherwise; the shifts and ~ run on bigint unless a multiplication or division by a power of two is exact and in range.
  • Float.from is Number(value), which rounds to nearest like the cast in C; Int.tryFrom of a Float tests Number.isInteger and the range against 2^63 exactly.
// Int64 + Int64 in the generated code (the slow path is a function of the runtime, shared by all operators)
function add(a, b) {
  if (typeof a === "number" && typeof b === "number") {
    const r = a + b
    if (r >= -9007199254740991 && r <= 9007199254740991) return r
  }
  return slowAdd(a, b) // BigInt sum, panics outside 64 bits, back to number if it fits 2^53 - 1
}

The boundary to JavaScript code is checked in both directions, for exported functions, callbacks, foreign declarations and the fields of a value handed across; a value is never truncated, rounded or turned into a different type on the way.

DirectionInt8 to Int32, UInt8 to UInt32Int, Int64, UInt64
JavaScript into TorbScripta number that is an integer in the type's rangea number that is a safe integer, or a bigint in the type's range; the value is made canonical on the way in
TorbScript into JavaScripta numbera number if the value lies within 2^53 - 1; otherwise the call throws a RangeError that names the function and the value

Anything else - NaN, a fraction, an integer-valued number of 2^53 or more (its exactness is already gone), a bigint outside the type's range - throws a RangeError before the TorbScript code runs; a value of another JavaScript type throws a TypeError. The .d.ts file therefore says number for every integer in a result and in a field, and number | bigint for the 64-bit types in a parameter. A package whose interface needs the full range in results (an identifier, a hash, a timestamp in nanoseconds) will want a bigint mapping for those types; the representation already holds the bigint, so that is a switch of the npm generator and is decided with it in #66, when a package asks for it.

Why this one. Four representations were compared on the points the record's rules decide: exact over 64 bits, the same panic as the C and VM back ends, speed, and what a .d.ts file can say. The measurement (section 2c) is a small micro benchmark in Node 24, one process per variant.

Exact over 64 bitsSame overflow panicSpeed.d.ts and interop
bigint everywhere (A with the range compared, B with a BigInt.asIntN(64, r) !== r round trip)yesyes, on every operator12 to 25 ns per iteration of the small-value loops; 4 to 7 ns per addition on large values; every value is a heap objectbigint in every signature, a list index needs Number(i), nothing works with Math, JSON, setTimeout or the DOM without a conversion
A pair of 32-bit halves (D; the shape of Closure's goog.math.Long, Kotlin/JS Long and Scala.js RuntimeLong)yesyes, from the carry of the high half13 ns per iteration of the small-value loop, 6 to 9 ns per addition; multiplication, division and shifts are multi-word routines (not measured)an object in every signature; the same conversions as bigint, and the boundary cannot offer a plain number without unpacking
Exact arithmetic on number with an overflow check (E; the way Dart on the web treats an int)no: a value from 2^53 to 2^63 panics where C and the VM compute itno: it panics at 2^53as fast as the winnernumber, the best fit
The canonical hybrid (C)yesyesthe fastest on small values, 4 to 7 times slower than bigint on values above 2^53number in every signature whose values fit; the bigint appears only outside 2^53 - 1

A BigInt64Array (or a Float64Array of pairs) is a way to store many of these, not another representation: an element is read as a bigint, so a Buffer of Int64 may use one and make the value canonical on the way out.

The third option fails the rule of section 2 (the language's semantics, not the host's) outright: it is a 53-bit integer. Of the other three, the hybrid wins where programs spend their time: the values of a loop counter, a length, an index, a count, a price in cents, are small, and there it costs about what exact arithmetic on a number costs (the runs of C and E are within noise of each other) and is 1.7 to 3.3 times faster than bigint and 1.9 to 2.4 times faster than the pair. A list stores them as plain numbers and indexes with them, and an Int crosses to JavaScript as what JavaScript code expects. Its price is the other end: a value above 2^53 - 1 takes the slow path, about 27 ns per addition against 4 to 7 ns for bigint alone, and that is where a hash with UInt64 wrapping arithmetic, a fixed-point value and a Timestamp (a count of nanoseconds, 1.7 x 10^18 today) live. The cost is paid by exactly those values, and no program that was correct in C becomes incorrect.

What is left to the implementation, and what would change the decision. The IR knows more than the table does: a loop counter of a Range, a length or an index is below 2^32, and the back end of #63 may drop the typeof tests for it. If a conformance benchmark of #63 shows a workload of large values (the digests, Random) slower than it can accept, the operators of UInt64 and of the wrapping family get a bigint-only code path inside the same representation; the boundary and the canonical form do not change, because they are properties of a value and not of an operator.

2b. The browser's capability table (decided 2026-10-23)#

The table is the browser column for a program compiled to JavaScript; PHP: follows #62 in every row and is not decided here. It is the input of the checker rule of #65, not its implementation. The packages are the ones on main (the capability packages are capabilityOf in compiler/src/package/archive.trb; the other std packages touch nothing outside the program and are summed up in the last row). A row says what the import of the package, or of the named part of it, is: real (the same behaviour, over the host's API), simulation (it works, over something else, and the operations that cannot be offered fail with the package's own error) or compile error at the import. compiler/src/project/capability.trb is the same table for the playground's torb in WebAssembly (the VM of RELEASE.md section 6) and stays as it is; where the two differ, because the playground holds the front end and the VM and has no bridge to the browser's APIs, the row says so.

PackageCapabilityBrowserReasoning
std/fsfilessimulationThe root / is the origin private file system (navigator.storage.getDirectory()): Chromium, Firefox and Safari have it, it asks for no permission and it outlives a reload (until the browser evicts the origin's storage). The File System Access API (showOpenFilePicker) needs a user gesture and exists in Chromium browsers only, so it can offer a mounted directory later but cannot be the root. Symbolic links, permission bits and owners do not exist there: those operations fail with an IoError that says so. The playground keeps its file system in memory; both are simulation
std/archive (extract)filessimulationFollows std/fs; the archive formats themselves are TorbScript and real
std/storagenonerealThe memory: driver is real, and the file: driver goes through std/fs: a simulation exactly as far as std/fs is
std/os/environmentenvironmentsimulationThe environment is empty, as in the playground; the lookup answers None and the listing is empty
std/os/entropyentropyrealcrypto.getRandomValues, in calls of at most 65536 bytes
std/os (the rest)the operating systemsimulationstd/os/src/browser/ exists: the target constants and the page size answer and the process identifier is 0; the version, host name, uptime, parent process, memory limit and the user directories fail with OsError.Unsupported; the temporary directory is /tmp of the file system above
std/processprocessescompile errorA page starts no program; a Web Worker is not a process and is std/task and std/parallel
std/networknetworkcompile errorA page has no sockets: no TCP, no UDP, and no name resolution it can ask for
std/tlsnetworkcompile errorIt is TLS over a TcpStream; the browser terminates TLS for fetch itself
std/httpnetworkcompile errorThe client could become a simulation over fetch (redirects and headers under the browser's rules, CORS), but it needs a transport native that every back end then provides. A compile error can be relaxed later without breaking a program and a dropped simulation cannot, so the table starts there, as the playground's does. When the client is added, the check is by name: Server and Handler stay a compile error
std/dnsnonerealMessages as values and the wire format, no natives; only a transport is missing, and that is std/network. capability.trb lists it as missing for the VM; the package is pure, so #65 should align that
std/timeclockrealClock.now is performance.now() for the monotonic reading and Date.now() for the wall clock, sleep is setTimeout; the browser rounds both to a coarser resolution than a machine, which the record does not hide: Instant and Timestamp stay nanoseconds in an Int64
std/sandboxscriptscompile errorLoading a script needs the front end and the VM inside the program; a program compiled to JavaScript has neither (the playground's torb has both, and keeps it real)
std/machinenonecompile errorThe kernel of the VM, raw words and the C runtime behind them; "not for programs" (real in the playground, which hosts the VM)
std/console, std/iononereal, simulationprint is console.log and printError is console.error, isTerminal answers false; the standard streams of std/io are a sink to the console and a standard input that is at its end
std/tasknonerealA Task is a promise (section 2); runToEnd() at the top level is a panic in a browser, as std/task already says
std/parallel, offload of std/tasknonesimulationThe chunks run one after another on the event loop. The result of a pipeline never depends on the number of workers (CONCURRENCY.md section 4), so one worker is exact and not an approximation; Web Workers are a speed-up for later, and need SharedArrayBuffer headers a page does not always have
std/randomnonerealSplitMix64 in TorbScript over UInt64; the same numbers as on a machine
std/cipher, std/digest, std/signaturenonerealTorbScript without natives; Web Crypto is asynchronous, so it cannot back these synchronous functions and is not used
std/resourcenonerealThe files of a ResourceDirectory are in the bundle
every other std packagenonerealbinary, collections, compression, core, encoding, expression, geometry, ip, iteration, json, linear, markdown, number, path, prelude, project, regex, stream, test, text, toml, uri and yaml reach nothing outside the program

There is no std/crypto or std/net: the first is std/cipher, std/digest and std/signature, the second std/network, std/tls and std/http. A foreign declaration names a symbol of C and is not a package; what it is in a JavaScript program is decided with #63. A server-side JavaScript host (Node, Deno, Bun) is another system, not the browser: if it becomes a target it gets its own column, expected to be real wherever the host has the API.

2c. The measurement behind 2a#

Node 24.21 (V8) on x86-64, one pinned core of a shared host under load, one process per variant, each cell the median of five runs after a warm-up, then the median of three such processes. The spread between the three was mostly under 10 % and up to 30 % in the noisiest cells (B sum, C and E in lcg and index), so only ratios above 1.5 are claimed. Times in milliseconds for the loop sizes in the header; "plain" is an unchecked number loop, the ceiling.

Variantsum (30 M iterations)lcg (20 M)fib 90 (27 M adds, 13 of 90 beyond 2^53)large (30 M adds, all beyond 2^53)index (30 M)
plain number, unchecked2589---
A bigint, range compared493492207209722
B bigint, asIntN round trip373325151123604
C canonical hybrid206195191814218
D pair of 32-bit halves (add only)401-230194523
E number, checked at 2^53 (not 64-bit)212196--215

sum is s + i and i + 1 with a compare; lcg is x = (x * 31 + i) % 1000003, a multiplication, a remainder and two additions; fib 90 is the 90th Fibonacci number computed 300 000 times; large is x + 1 and x - 1 on a value that stays at 2^60; index sums a list of a million Ints by index, 30 times. These are ratios on one engine, not promises: V8 changes, and A against B (the same computation) shows how much of the bigint cost is the shape of the check. No public benchmark compares these four on a checked 64-bit Int, so the program is here, to be run again by #63 on the forge's runners with the engines of the day:

// Representations of a checked 64-bit Int in JavaScript. One variant per process: node int64.js A|B|C|D|E|plain
"use strict";
const MAXS = Number.MAX_SAFE_INTEGER, MAX = 2n ** 63n - 1n, MIN = -(2n ** 63n);
const panic = () => { throw new Error("arithmetic overflow"); };

// A: BigInt everywhere, the range compared after the operation
const A = { zero: 0n, from: (n) => BigInt(n), lt: (a, b) => a < b, at: (xs, i) => xs[Number(i)],
  add(a, b) { const r = a + b; if (r > MAX || r < MIN) panic(); return r; },
  mul(a, b) { const r = a * b; if (r > MAX || r < MIN) panic(); return r; },
  rem(a, b) { if (b === 0n) panic(); return a % b; } };
// B: BigInt everywhere, the range checked by a round trip through asIntN
const B = { ...A,
  add(a, b) { const r = a + b; if (BigInt.asIntN(64, r) !== r) panic(); return r; },
  mul(a, b) { const r = a * b; if (BigInt.asIntN(64, r) !== r) panic(); return r; } };
// C: canonical hybrid, a number while |v| <= 2^53-1 and a BigInt outside; the slow path is taken only when it must be
const MAXSN = BigInt(MAXS), big = (v) => (typeof v === "bigint" ? v : BigInt(v));
const demote = (r) => { if (BigInt.asIntN(64, r) !== r) panic(); return r >= -MAXSN && r <= MAXSN ? Number(r) : r; };
const C = { zero: 0, from: (n) => n, lt: (a, b) => a < b, at: (xs, i) => xs[i],
  add(a, b) {
    if (typeof a === "number" && typeof b === "number") { const r = a + b; if (r >= -MAXS && r <= MAXS) return r; }
    return demote(big(a) + big(b));
  },
  mul(a, b) {
    if (typeof a === "number" && typeof b === "number") { const r = a * b; if (r >= -MAXS && r <= MAXS) return r + 0; }
    return demote(big(a) * big(b));
  },
  rem(a, b) {
    if (b === 0) panic();
    if (typeof a === "number" && typeof b === "number") return (a % b) + 0;
    return demote(big(a) % big(b));
  } };
// D: two int32 halves in an object (hi signed, lo unsigned); add only
function Pair(hi, lo) { this.hi = hi; this.lo = lo; }
const D = { zero: new Pair(0, 0), from: (n) => new Pair(Math.floor(n / 4294967296) | 0, n >>> 0),
  lt: (a, b) => a.hi < b.hi || (a.hi === b.hi && a.lo < b.lo), at: (xs, i) => xs[i.lo],
  add(a, b) {
    const lo = a.lo + b.lo, hi = a.hi + b.hi + (lo > 4294967295 ? 1 : 0);
    if (hi > 2147483647 || hi < -2147483648) panic();
    return new Pair(hi, lo >>> 0);
  } };
// E: exact arithmetic on number with an overflow check at 2^53 (not a 64-bit Int: it panics where C does not)
const E = { zero: 0, from: (n) => n, lt: (a, b) => a < b, at: (xs, i) => xs[i],
  add(a, b) { const r = a + b; if (r > MAXS || r < -MAXS) panic(); return r; },
  mul(a, b) { const r = a * b; if (r > MAXS || r < -MAXS) panic(); return r + 0; },
  rem(a, b) { if (b === 0) panic(); return (a % b) + 0; } };

// sum: 0 + 1 + ... + (n-1); small values, add and compare
function sum(R, n) { let s = R.zero, i = R.zero; const one = R.from(1), N = R.from(n);
  while (R.lt(i, N)) { s = R.add(s, i); i = R.add(i, one); } return s; }
// lcg: x = (x * 31 + i) % 1000003; small values, mul, add and rem
function lcg(R, n) { let x = R.from(7), i = R.zero;
  const m = R.from(1000003), k = R.from(31), one = R.from(1), N = R.from(n);
  while (R.lt(i, N)) { x = R.rem(R.add(R.mul(x, k), i), m); i = R.add(i, one); } return x; }
// fib: the 90th Fibonacci number, repeatedly; values grow to 2.9e18, the last twelve additions are beyond 2^53
function fib(R, reps) { let last = R.zero; for (let r = 0; r < reps; r++) { let a = R.zero, b = R.from(1);
  for (let i = 0; i < 90; i++) { const t = R.add(a, b); a = b; b = t; } last = a; } return last; }
// large: two additions per iteration on a value that stays above 2^53
function large(R, n) { let x = R === D ? new Pair(268435456, 0) : 2n ** 60n; const one = R.from(1), minus = R.from(-1);
  for (let i = 0; i < n; i++) { x = R.add(x, one); x = R.add(x, minus); } return x; }
// index: a list of one million Ints summed by index, 30 passes
function index(R, passes) { const n = 1000000, xs = new Array(n); for (let i = 0; i < n; i++) xs[i] = R.from(i % 1000);
  const one = R.from(1), N = R.from(n); let s = R.zero;
  for (let p = 0; p < passes; p++) {
    let i = R.zero;
    while (R.lt(i, N)) { s = R.add(s, R.at(xs, i)); i = R.add(i, one); }
  }
  return s; }

const median = (a) => a.sort((x, y) => x - y)[a.length >> 1];
function ms(f) { // one warm-up run, then the median of five
  f(); const ts = [];
  for (let i = 0; i < 5; i++) {
    const t = process.hrtime.bigint(); f(); ts.push(Number(process.hrtime.bigint() - t) / 1e6);
  }
  return median(ts).toFixed(0);
}
const name = process.argv[2], out = [name];
if (name === "plain") { // unchecked number, the ceiling
  out.push(ms(() => { let s = 0; for (let i = 0; i < 30e6; i++) s += i; return s; }),
    ms(() => { let x = 7; for (let i = 0; i < 20e6; i++) x = (x * 31 + i) % 1000003; return x; }));
} else {
  const R = { A, B, C, D, E }[name];
  out.push(ms(() => sum(R, 30e6)), R.mul ? ms(() => lcg(R, 20e6)) : "-", R === E ? "-" : ms(() => fib(R, 300000)),
    R === E ? "-" : ms(() => large(R, 15e6)), ms(() => index(R, 30)));
}
console.log(out.join("\t"));

3. Open#

  1. The oldest version of PHP that is supported, from the research into Fibers and the event-loop extensions: the owner's decision, collected on #62. The PHP column of the table in section 2b follows it.
Esc