runtime:process
Host process information: environment, arguments, working directory, platform, signals, and exit. Aligned in spirit with the WinterTC CLI-API proposal.
Environment access is gated on Env; the signal exports on Signals. Exposed as an ES module under the runtime: scheme. Status: Available.
Import
import { env, args, parseArgs, stdin, platform, arch, cwd, exit, unmask } from "runtime:process"; // Or the default aggregate: import process from "runtime:process";
Exports
| Export | Type | Description |
|---|---|---|
env | Record<string, string | Secret> | Environment variables as a mutable in-process object, seeded from a host snapshot taken when the module is evaluated (plus any --env-file values). Reads, writes, and deletes work in-process; they do not propagate to the host or to child processes. Assigned values are coerced to strings (env.PORT = 8080 stores "8080"; a symbol throws). Secret-keyed values are Secret wrappers (see below). |
args | readonly string[] | Program arguments after the runtime binary and the script (or -e snippet). Frozen. Excludes the executable and script path. |
parseArgs(config?) | (config?) => { values, positionals, tokens? } | Parse args against declared options — Node's util.parseArgs. No capability. |
platform | string | Host operating system — the OS-native std value ("linux" | "macos" | "windows"). |
arch | string | Host CPU architecture — the OS-native std value ("x86_64" | "aarch64" | "arm"). |
cwd() | () => string | Current working directory. A function (not a value) because it can change during a run. |
exit(code = 0) | (code?: number) => never | Records the exit code and halts execution immediately — code after the call does not run. The embedder treats it as a clean exit, not an error. |
unmask(value) | (value: string | Secret) => string | Reveal the real value of a masked Secret. A plain string is returned unchanged, so unmask(env.ANY) is always safe. |
Secret | class | Opaque holder for a masked env value. Renders as "[redacted]" in console, string coercion, and JSON. Call unmask() to read it. |
signals() | () => SignalName[] | Signal names this platform can deliver. Signals. |
onSignal(sig, fn) | (SignalName, (SignalName) => void) => void | Run fn when sig arrives, suppressing its default action. Signals. |
offSignal(sig, fn) | (SignalName, (SignalName) => void) => void | Remove a handler; removing the last one restores the default action. Signals. |
stdin | StdIn | The process's standard input: readable, lines(), question(), setRawMode(). No capability. |
stdout | StdStream | The process's own standard output — write(chunk), isTTY, columns, rows. No capability, like console.log. |
stderr | StdStream | The same, for standard error. |
permissions | object | What this run may reach (see below). No capability. |
memoryUsage() | () => object | { heapUsed, heapLimit, external } in bytes, for this agent's isolate. No capability. |
cpuTime() | () => number | CPU milliseconds this agent's thread has used. No capability. |
uptime() | () => number | Milliseconds since this agent started. No capability. |
unrefTimer(id) / refTimer(id) | (id: number) => void | Whether a timer keeps the program alive. No capability. |
env — reading and writing
import { env } from "runtime:process"; console.log(env.HOME); // read env.FEATURE_FLAG = "on"; // write (in-process only) delete env.SECRET; // delete (in-process only)
args — program arguments
// esrun app.js build --watch import { args } from "runtime:process"; console.log(args); // ["build", "--watch"]
parseArgs — options and flags
Node's util.parseArgs, reading args by default:
// esrun server.js start -p 3000 --verbose import { parseArgs } from "runtime:process"; const { values, positionals } = parseArgs({ options: { port: { type: "string", short: "p", default: "8080" }, verbose: { type: "boolean", short: "v" }, }, allowPositionals: true, }); console.log(values.port, values.verbose, positionals); // "3000" true ["start"]
| Config | Default | Meaning |
|---|---|---|
args | args | The strings to parse. |
options | {} | By long name: type ("string" or "boolean"), short (one character), multiple, default. |
strict | true | Throw on an unknown option or a missing or unexpected value. |
allowPositionals | !strict | Accept arguments that are not options. |
allowNegative | false | Accept --no-<name> for a boolean, storing false. |
tokens | false | Also return the parsed tokens. |
values has no prototype. --name=value, -nvalue, -abc groups and -- (everything after it is positional) are understood.
signals — graceful shutdown
Watching a signal suppresses its default action, which is what lets a SIGTERM drain instead of kill.
import { onSignal, offSignal } from "runtime:process"; const shutdown = async (signal) => { offSignal(signal, shutdown); // a second ^C should kill, not queue await server.stop(); }; onSignal("SIGINT", shutdown); onSignal("SIGTERM", shutdown);
| Platform | Deliverable |
|---|---|
| Unix | SIGINT, SIGTERM, SIGHUP, SIGUSR1, SIGUSR2 |
| Windows | SIGINT, SIGBREAK |
While anything is watched the program stays running to receive it, as in Node and Deno; removing the last handler releases it. Repeated deliveries coalesce.
exit — stopping the run
import { exit } from "runtime:process"; if (failed) exit(1); // records the code and halts immediately exit(); // defaults to 0
stdin — reading input
import { stdin } from "runtime:process"; // A question on standard error, the answer from standard input. const name = await stdin.question("Name? "); // A filter: `cat data.txt | esrun count.js` let count = 0; for await (const line of stdin.lines()) count++; console.log(count);
| Member | Description |
|---|---|
isTTY | Whether stdin is a terminal. |
readable | A ReadableStream<Uint8Array> of the bytes as they arrive. |
lines() | Each line without its terminator, until the end of input. |
question(query?) | Writes query to standard error, resolves with the next line, or null at the end of input. |
setRawMode(on) | Keys as they are pressed, with no echo or line editing. ^C arrives as the byte 0x03. Throws when stdin is not a terminal; Unix only for now. |
isRaw | Whether raw mode is on. |
All of these, and the global prompt(), read from one buffer, so a line one of them read past is not lost to the next. The runtime restores the terminal from raw mode when the program ends, however it ends.
// One keypress, without Enter. stdin.setRawMode(true); const { value } = await stdin.readable.getReader().read(); stdin.setRawMode(false);
For a quick synchronous question, the web's prompt(), confirm() and alert() are globals. When stdin is not a terminal, they return at once rather than wait.
No capability is needed. A worker cannot read stdin, because a process has one input.
stdout — drawing on a terminal
console.log formats a value, appends a newline, and goes wherever the host pointed it. That is right for a log line and wrong for a display: a spinner is a carriage return and no newline, and a progress bar rewrites the line it is already on.
import { stdout } from "runtime:process"; if (stdout.isTTY) { stdout.write(`\r${bar(done / total, stdout.columns ?? 60)}`); } else { console.log(`${done}/${total}`); }
| Member | |
|---|---|
write(chunk) | Exactly these bytes, flushed. No newline is added. A string is UTF-8; an ArrayBuffer or view is written as it is. |
isTTY | Whether this stream is attached to a terminal. |
columns / rows | The terminal's size, or undefined when there is no terminal or the host cannot say. |
Ask isTTY before you draw. A spinner redrawn with \r into a log file is a file of spinner frames, and colour escapes in a pipe are noise in somebody's grep.
The size comes from the terminal, not from $COLUMNS — a shell exports that to itself rather than to a child, and it is stale the moment the window is dragged. Where the host cannot answer it says so rather than reporting a plausible 80.
Neither needs a capability, for the reason console.log does not: writing to the stream this program was started with reaches nothing it was not already handed, so --deny-all still leaves a program able to say what it is doing.
permissions — what this run may reach
Fixed at launch by esrun's permission flags, so has() is a synchronous boolean: there is nothing to request and no prompt to await.
import { permissions } from "runtime:process"; permissions.denied; // ["read", "write"] — [] when nothing is denied permissions.has("net"); // true if (permissions.has("write")) await fs.write("cache.json", data);
| Export | Type | Description |
|---|---|---|
permissions.denied | readonly PermissionName[] | Names this run may not use, in capability order. |
permissions.has(name) | (PermissionName) => boolean | Whether name is available. Throws TypeError for any other name, and for a second argument. |
PermissionName is "read", "write", "imports", "net", "listen", "env", "run", "signals" — the same words the --deny-<name> flags use. Needs no capability, so it answers with everything denied too.
A scoped grant reports true: --allow-env=PORT grants the env capability and narrows what it yields, so has("env") means "you may read an environment variable", not "you may read this one". The same holds for --allow-net=<hosts> and the rest.
There is no per-value form — has("read", "/etc/passwd") throws. Which paths and hosts are allowed is set by the deployment, not asked about by the application, and the exact answer for one value is to make the call and catch ERR_PERMISSION_DENIED. A path is judged after the runtime resolves it, so an answer given in advance could be stale by the time the call happens.
Secrets — masked by default
Secret-bearing keys are exposed as a Secret that renders as [redacted] in console, strings, and JSON. A key qualifies if it ends in _KEY, _TOKEN, _SECRET, _PASS(WORD), or contains CREDENTIAL/AUTH.
import { env, unmask } from "runtime:process"; // Keys ending in *_SECRET(S) / *_PASSWORD(S) are masked by default. console.log(env.DB_PASSWORD); // [redacted] console.log(`${env.DB_PASSWORD}`); // [redacted] JSON.stringify(env); // ..."DB_PASSWORD":"[redacted]"... const pw = unmask(env.DB_PASSWORD); // real value, explicit unmask(env.DB_HOST); // plain strings pass through
Call unmask(value) for the real string; plain values pass through unchanged.
Masking stops accidental log leaks — code you run can still call unmask itself.
Load values from a file with esrun --env-file=.env (no auto-discovery; the OS environment wins unless --env-override is passed).
Timers that do not hold the process
A setInterval for a heartbeat or a periodic flush keeps a program running for ever. unrefTimer lets it go: the timer still fires while anything else keeps the program alive, but no longer keeps it alive on its own. refTimer undoes it.
import { unrefTimer } from "runtime:process"; unrefTimer(setInterval(flushMetrics, 10_000));
id is what setTimeout or setInterval returned. A cleared or fired timer is left alone. In a worker they apply to that worker's own timers; the worker itself stays until it is terminated, or until its parent calls worker.unref(). These match Deno's Deno.unrefTimer and Deno.refTimer; Node puts unref() on the timer object, and setTimeout returns a number here, as on the web.
Reporting on yourself
import { cpuTime, memoryUsage, uptime } from "runtime:process"; const { heapUsed, heapLimit } = memoryUsage(); if (heapLimit - heapUsed < 32 * 1024 * 1024) shedLoad();
| Field | |
|---|---|
heapUsed | what V8 has allocated and not collected, for this isolate |
heapLimit | this isolate's ceiling — --max-heap, or a worker's memory option |
external | ArrayBuffers and other memory V8 holds outside its heap |
heapLimit - heapUsed is real headroom: exceed it and the heap guard ends this agent. Per isolate, so a worker answers for itself:
// in a worker started with { memory: 64 } memoryUsage().heapLimit; // 67108864, not the parent's cpuTime(); // CPU burned by *this worker's* thread uptime(); // ms since *this worker* started
uptime() is not performance.now(). A worker is handed its parent's clock, so performance.now() counts from when the process's runtime was built and reads the same in every agent.
Utilisation is two reads and a division:
const [cpu0, t0] = [cpuTime(), uptime()]; await work(); const busy = (cpuTime() - cpu0) / (uptime() - t0);
cpuTime() is total CPU, not a user/system split. The split needs Mach on macOS, where a wrong struct layout is a memory-safety bug rather than a wrong number, so it is reported nowhere rather than on two platforms out of three.
All three report only what the caller could discover about itself anyway — its ceiling by allocating until it is stopped, its CPU and age by counting — which is the same rule platform and args follow.
The process's resident set, total CPU and uptime are about the other agents in the process, so they are not here. They are metrics().process in runtime:diagnostics, behind diagnostics.
Errors
| Error | When |
|---|---|
TypeError | unmask() gets something other than a string or a Secret from env. |
DOMException | name "NotAllowedError" — the Env capability is not granted (env access), or Signals (signal access). |
TypeError | onSignal() gets a name this runtime does not know. |
Error | stdin read in a worker (a process has one input), or setRawMode() when stdin is not a terminal or on Windows. |
TypeError | parseArgs(): code ERR_PARSE_ARGS_UNKNOWN_OPTION, ERR_PARSE_ARGS_INVALID_OPTION_VALUE or ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL for the command line; ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE for config. |
Error | onSignal() gets a signal this platform cannot deliver — better than a handler that never fires. |
The default export is an object bundling all named exports — useful for a single import binding — but named imports are preferred for clarity and tree-shaking.