Docs
/

@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...

@leumas/runcode

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

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:

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:

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.

OptionDefaultMeaning
codeInline source. Mutually exclusive with file.
filePath to a source file. Relative paths resolve against cwd.
args[]Arguments after the script, as an argv array.
inputSerialized to one JSON argv element appended last. Omitted entirely when absent.
varsVariable 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.
stdinWritten to standard input, which is then closed. Use this for large payloads — see below.
envMerged over process.env.
cwdWorking directory.
timeoutMs120000Kill after this long. 0 disables the timer.
maxBytes8388608Cap 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:

FieldMeaning
okTrue only when the program ran and exited 0. Check this, don't just await.
outputstdout parsed as one JSON document, else null. Parsed on the failure path too.
stdout / stderrAlways raw, always present. Many tools log to stderr on success.
exitCodenull when killed or never started.
refusedThis package declined to act — unknown language, no source, both sources. Nothing spawned.
unavailableThe toolchain is not installed. The fix is an install, so it is not a refused.
timedOut / truncatedKilled by the clock, or by the byte cap (the text kept is the prefix).
errorWhy it is not ok, as a sentence. For a compile failure this is the compiler's own diagnostics.
installHow to get the toolchain. Non-empty only when unavailable.
binThe executable actually used — the interpreter, or the compiler for a compiled language.
compileMsCompiled 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.

{
  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.)

{
  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

Interpretedpython · 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

Compiledcpp · 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.

What it exports

16 exports: 3 components, 0 hooks, 13 helpers. Import from @leumas/runcode.

BUILTIN_IDSDEFAULT_MAX_BYTESDEFAULT_TIMEOUT_MSavailableclearAvailabilityCacheclearScratchgetLanguagekillAlllistLanguagesregisterLanguagerunBoundedrunCodescratchRootsupportsVarsvalidateLanguagevarsLanguages
Source shared/packages/runcode/README.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt