# @leumas/runcode

ONE seam for running any language from JS - runCode('python'|'rust'|'perl'|..., { code | file }), plus variable crossing: vars become native variables and emit() returns a value, so runs chain across...


One seam for running **any language** from JS. A language is a declarative table row, not a code path.

```js
import { runCode } from '@leumas/runcode';

await runCode('python', { code: 'print(1+1)' });
await runCode('node',   { file: 'scripts/report.mjs', args: ['--week'] });
await runCode('rust',   { code: rustSource, input: { n: 10 } });
await runCode('bat',    { code: 'echo hi' });
```

**Variable crossing** — values go in as native variables, and come back out through `emit`:

```js
const r = await runCode('python', {
  code: 'emit({"sum": a + b, "who": name.upper()})',
  vars: { a: 20, b: 22, name: 'leumas' },
});
r.output; // { sum: 42, who: 'LEUMAS' }
```

The same `vars` and the same expected `output` work in `node`, `bun`, `deno`, `typescript`, `ruby`,
`perl` and `php` — only the snippet changes. And because `output` is already a legal `vars`, runs
**chain across languages** with nothing in between:

```js
const one   = await runCode('python', { code: 'emit({"total": sum(nums)})', vars: { nums: [1,2,3] } });
const two   = await runCode('node',   { code: 'emit({half: total / 2})',    vars: one.output });
const three = await runCode('perl',   { code: 'emit({ok => $half > 2})',    vars: two.output });
```

## Why it exists

`@leumas/pyrun` already owns Python *spawning* and *sidecar lifecycle*, and it is still the right
answer for a long-running FastAPI sidecar. What it does not give you is a **call convention** — so
every place that reached another language grew its own copy of the same four problems: find the
interpreter, build an argv, bound the run, parse the result. This package solves those once, for
every language, and delegates Python interpreter discovery back to `pyrun` rather than owning a
second opinion about which Python this repo means.

## The one invariant

**`runcode` never decides who may call it.** It has no notion of a session, a role, a tenant or a
policy, and it deliberately accepts inline `code`. That is safe *here*: first-party JS running in
this process could already call `spawn()` itself, so an easier way to do it escalates nothing.

It stops being safe the moment a language ref crosses a **trust boundary**. A stored database row, an
HTTP body, a fabric dispatch, a plugin — anything whose author is not the person who deployed this
process — must be given `file` plus an allow-list, **never `code`**.
`products/standalone/leumas-node/src/exec/python.js` is the worked example of that seam: two
empty-by-default gates, a realpath fence, and refusal by name. Its header records why, and it is
worth reading before wiring this package into anything outward-facing.

Structural properties, always on: argv **arrays** only (`shell: false` — nothing in an input can be a
command), a wall-clock timeout with SIGTERM then SIGKILL, and a byte cap on captured output.

## API — six exports

### `runCode(language, opts) → Promise<result>`

Give it **either** `code` (inline source, written to a scratch file with the language's extension and
deleted afterwards) **or** `file` (a path used in place). Both, or neither, is a refusal.

| Option | Default | Meaning |
|---|---|---|
| `code` | — | Inline source. Mutually exclusive with `file`. |
| `file` | — | Path to a source file. Relative paths resolve against `cwd`. |
| `args` | `[]` | Arguments after the script, as an argv array. |
| `input` | — | Serialized to **one JSON argv element** appended last. Omitted entirely when absent. |
| `vars` | — | **Variable crossing** — each key becomes a native variable, and the script gains `emit(value)`. Needs `code` and a crossing language. Travels by file, so no argv cap. |
| `stdin` | — | Written to standard input, which is then closed. **Use this for large payloads** — see below. |
| `env` | — | Merged **over** `process.env`. |
| `cwd` | — | Working directory. |
| `timeoutMs` | `120000` | Kill after this long. `0` disables the timer. |
| `maxBytes` | `8388608` | Cap on stdout+stderr **combined**. |
| `onStderr` | — | `(line) => void` per stderr line, live. stdout is buffered until exit. |

The result — **it never throws for an expected condition.** A missing toolchain, an unknown language,
a non-zero exit and a timeout all *resolve* with `ok: false` and a field that tells them apart:

| Field | Meaning |
|---|---|
| `ok` | True **only** when the program ran and exited 0. Check this, don't just `await`. |
| `output` | `stdout` parsed as one JSON document, else `null`. Parsed on the failure path too. |
| `stdout` / `stderr` | Always raw, always present. Many tools log to stderr on success. |
| `exitCode` | `null` when killed or never started. |
| `refused` | This package declined to act — unknown language, no source, both sources. Nothing spawned. |
| `unavailable` | The toolchain is not installed. The fix is an install, so it is not a `refused`. |
| `timedOut` / `truncated` | Killed by the clock, or by the byte cap (the text kept is the **prefix**). |
| `error` | Why it is not `ok`, as a sentence. For a compile failure this is the **compiler's own diagnostics**. |
| `install` | How to get the toolchain. Non-empty only when `unavailable`. |
| `bin` | The executable actually used — the interpreter, or the compiler for a compiled language. |
| `compileMs` | Compiled languages only. `0` means the artifact cache hit. |

`output` being `null` does not mean failure, and a script that genuinely printed `null` is
indistinguishable from one that printed prose — use `stdout` when that distinction matters.

### `available(language, { refresh }) → Promise<{ ok, bin, version, unavailable, install, reason }>`

Is this language runnable here? Memoised for ten minutes, since a probe costs a spawn; pass
`refresh: true` right after installing a toolchain.

### `listLanguages() → string[]`

Every registered id. Presence means the **row** exists, not that the toolchain is installed.

### `varsLanguages() → string[]` · `supportsVars(language) → boolean`

Which languages can take `vars`. Membership means the **row** declares a prelude, not that the
toolchain is installed.

### `registerLanguage(spec, { overwrite }) → row`

Teach it a language this package does not ship. Throws on a malformed spec or on shadowing an
existing id without `overwrite` — those are wire-up errors, not runtime conditions, so unlike the
rest of the package they throw.

## Adding a language

A row in `src/runtimes.js`. If you ever need `if (language === 'x')` outside that file, the row is
missing a field and the field is the fix.

```js
{
  id: 'ruby',
  ext: '.rb',
  bin: { env: ['RUNCODE_RUBY'], candidates: ['ruby'] },
  argv: ({ file, args }) => [file, ...args],
  install: 'Install Ruby (https://ruby-lang.org).',
}
```

**Compiled languages** add `compile` and **omit `argv`** — the built artifact *is* the executable, so
`args` reach it untouched. (Declaring `argv` there invites `[file, ...args]`, which hands the program
its own path as `argv[1]`; the table rejects it for that reason.)

```js
{
  id: 'rust',
  ext: '.rs',
  bin: { env: ['RUNCODE_RUSTC'], candidates: ['rustc'] },
  compile: ({ src, out }) => ['-O', src, '-o', out],
  install: 'Install Rust (https://rustup.rs).',
}
```

Artifacts are cached by a hash of the **source plus the compiler's version string** — hashing the
source alone would reuse a binary built by a compiler the operator has since replaced.

## The shipped rows

**Interpreted** — `python` · `node` · `bun` · `deno` · `typescript` · `ruby` · `perl` · `php` · `lua` ·
`awk` · `java` (single-file source mode, JEP 330) · `r` · `julia` · `elixir` · `zig` (`zig run`) ·
`swift` · `dart` · `bash` · `sh` · `powershell` · `bat`

**Compiled** — `cpp` · `c` · `rust` · `go`

`listLanguages()` is the live answer; the list above goes stale, the function does not.

The `typescript` row is served by whichever of `bun`, `deno` or `tsx` is installed, and its `argv`
branches on which one answered — that is why a row's `argv` is handed `bin`. One row means a caller
never has to ask which TypeScript runner a machine has.

## Variable crossing, in full

`vars` binds each key as a **native variable** and defines **`emit(value)`**, which prints one JSON
document — the same thing `output` parses. So a value crosses *in* as a variable and back *out* as a
value, and `output` from one run is a legal `vars` for the next. That is what makes runs chain across
languages.

Supported in **`python` · `node` · `bun` · `deno` · `typescript` · `ruby` · `perl` · `php`** — ask
`varsLanguages()`. A row without a prelude (`awk`, `lua`, `java`, and every compiled row) **refuses**
`vars` by name and points at `input`; it never ignores them silently.

Three rules, each with a reason:

- **`vars` needs `code`, not `file`.** The prelude is prepended to the source, and this will not
  rewrite a file on disk. Pass `input` to a file instead.
- **Keys must be plain identifiers** (`[A-Za-z_][A-Za-z0-9_]*`) and must not be a keyword in *any*
  supported language. `class` binds fine in Python and is a syntax error in Ruby, PHP and JS —
  refusing it everywhere is what keeps a snippet portable. `emit` is reserved too.
- **The prelude is exactly one line**, so every reported line number is off by exactly one. A
  multi-line preamble would make every stack trace quietly wrong by an unknown amount.

Two language-specific notes:

- **Perl and PHP names carry their sigil.** A var named `total` arrives as `$total`. That is those
  languages being themselves, not an inconsistency.
- **A PHP snippet must not reopen `<?php`** — the prelude already did, and PHP outside a tag is echoed
  verbatim.

Because the payload travels by **file**, `vars` is not subject to the argv cap below — a 400 KB
object crosses fine, where the same payload through `input` is refused.

## Two limits worth knowing before you wire this in

**`input` is an argument list, not a file transfer.** The whole argv is capped at **24 KB on Windows**
(CreateProcess allows 32767 characters for the entire command line, shared with the interpreter path
and flags) and 256 KB elsewhere. Over that, `runCode` **refuses** with a sentence naming the size and
the limit — rather than letting the OS answer `spawn ENAMETOOLONG`, which names neither the offending
argument nor the fix. Pass large payloads on **`stdin`**, which has no such cap.

**Output is decoded across chunk boundaries.** stdout/stderr arrive as byte chunks that split wherever
the pipe buffer fills — routinely mid-character. Decoding each chunk independently corrupts every
split multi-byte sequence, so this uses one `StringDecoder` per stream. `maxBytes` is counted in real
**bytes** off the buffer, not in decoded characters.

Relatedly, the `python` row passes **`-X utf8`**: on Windows Python still defaults stdout to the legacy
ANSI codepage, so printing any character outside Latin-1 raises `UnicodeEncodeError` and kills the
script. `PYTHONIOENCODING` cannot fix that here because `-I` implies `-E` (ignore all `PYTHON*` env
vars) — the flag form survives isolation, so the row keeps both.

## Scratch files

Inline `code` and compiled artifacts live under `resolveDataRoot()/runcode` (override with
`RUNCODE_SCRATCH_DIR`), so the packaged exe behaves like dev and an operator reclaiming disk has one
directory to look at. `clearScratch()` empties it. Source files are deleted after every run,
including the timeout path.

## Tests

```
node --test test/*.test.js
```

Three suites:

- **`runcode.test.js`** — toolchain-free. It only needs `node`, which is what runs the tests.
- **`toolchains.test.js`** — python and the compiled lane. **Skips loudly** when a toolchain is
  absent, naming what is missing and how to install it. A green run does not mean g++ was exercised;
  read the skip lines.
- **`vars.test.js`** — variable crossing and cross-scripting. Every installed crossing language runs
  the *same logical program* and must produce the *same* `output`; a three-language chain
  (python -> node -> perl) passes values with no glue; and each refusal is asserted separately,
  because they have three different fixes.
- **`integration.test.js`** — does it *fit this codebase*: parity with `@leumas/pyrun`'s interpreter
  ladder, binding behind `@leumas/invoke`'s `bindDescriptor`, running through the real adapter
  registry, adding no signal handlers of its own (`@leumas/lifecycle` owns those), surviving a
  SIGTERM-ignoring child, driving `check-toolchains.mjs`, and the no-shell guarantee across every
  installed language. Cross-package modules are reached by **dynamic import from the workspace root**
  — never a relative path climbing out of the package, which `pnpm check:escapes` forbids — and each
  such test skips when the module is not on disk.


---
Source: shared/packages/runcode/README.md
Canonical: https://docs.leumas.tech/p/packages/runcode
