Use as a library
@nimbus-sh/core is the OS without the Worker: the durable SQLite
filesystem, the shell with 60+ Unix commands, and the WASI runtime layer.
It has no Cloudflare dependency. You hand it a SQLite and get back .fs
and .exec.
In bun or node
Section titled “In bun or node”import { Database } from 'bun:sqlite';import { NimbusWorkspace } from '@nimbus-sh/core/workspace';import { writeText } from '@nimbus-sh/core/vfs/vfs.js';
const db = new Database('workspace.sqlite');const sql = { exec(q, ...p) { const st = db.query(q); if (st.columnNames.length === 0) { db.run(q, ...p); return []; } return st.all(...p); },};const transactions = { storage: { transactionSync: (cb) => db.transaction(cb)() } };
const ws = await NimbusWorkspace.create({ sql, transactions, generation: 1 });
await writeText(ws.fs, '/home/user/hello.txt', 'hi\n');await ws.exec('cat /home/user/hello.txt | wc -c'); // { stdout: '3\n', exitCode: 0 }node:sqlite works the same way. The workspace behaves as a tenant in a
database you own. It creates and touches only its own tables, and
destroy() drops those tables without calling deleteAll().
transactionSync must be a real transaction. An implementation that only
calls the callback turns every atomic write into a torn one.
In your own Durable Object
Section titled “In your own Durable Object”The same class runs over ctx.storage.sql. The host must supply a
generation that never repeats across instances. Pids derive from it, so
a repeated generation gives a dead process live write authority. Persist a
counter and bump it once per instance. A constant or Date.now() does not
work:
import { DurableObject } from 'cloudflare:workers';import { NimbusWorkspace } from '@nimbus-sh/core/workspace';
export class Workspace extends DurableObject { private ws?: Promise<NimbusWorkspace>;
private workspace(): Promise<NimbusWorkspace> { this.ws ??= (async () => { const generation = ((await this.ctx.storage.get<number>('generation')) ?? 0) + 1; await this.ctx.storage.put('generation', generation); return NimbusWorkspace.create({ sql: this.ctx.storage.sql, transactions: this.ctx, generation, }); })(); return this.ws; }
async exec(command: string) { return (await this.workspace()).exec(command); }}Cold starts, hibernation wakes, and resets all re-instantiate the class.
The counter bumps once per instance, and the code uses the bumped value
only after the put resolves.
Host mounts in df and mount
Section titled “Host mounts in df and mount”The workspace has one namespace: a CompositeVFS at ws.filesystem.vfs,
with SQLite at /, /proc and /dev. Mount your own filesystems on it, and
df, mount and /proc/mounts list them in mount order. A mounted VFS
describes itself with describe() and usage():
import { NimbusWorkspace } from '@nimbus-sh/core';import { MemoryVFS } from '@nimbus-sh/core/vfs/memory.js';
const ws = await NimbusWorkspace.create({ sql, transactions, generation });
// Any VFS can be mounted. This one also says what it is, for df and mount.const shared = Object.assign(new MemoryVFS(), { describe: () => ({ source: 'r2:team-bucket', type: 'r2', options: ['rw'] }), usage: async () => null, // or { size, used, available } in bytes});ws.filesystem.vfs.mount('/shared', shared);
await ws.exec('mount'); // ... r2:team-bucket on /shared type r2 (rw)A mount whose usage() returns null shows in df -a, in df <path> for a
path on it, and in mount and /proc/mounts.
A mounted VFS without writeRange cannot write part of a file in place. A
process’s open file on it buffers its writes (at most 8 MiB per descriptor;
a write past that fails with EFBIG) and writes the whole file back on
fsync, on the last close and when the process exits. The process that
wrote reads and stats its own writes at once. Another process sees the
mount’s content, and so the writes only after that write-back.
A mounted VFS without readRange answers a ranged read through the
namespace with ENOTSUP, rather than read the whole file in its place. A
process’s readers (cat, head, tail, a descriptor’s read) then read
the whole file and cut it; readRangeOrWhole from
@nimbus-sh/core/vfs/vfs.js does the same for a reader of your own that can
bear it.
By default the namespace resolves a mounted path one component at a time,
asking the backend for a stat of each directory on the way. A backend that
resolves a whole path itself, as a network filesystem’s server does (a
device tunnel, a container, a Drive, where every call is a round trip), is
mounted with resolvesPaths:
ws.filesystem.vfs.mount('/pc', ({ cred }) => devices.for(cred.uid), { resolvesPaths: true, absentReason: () => 'no device connected',});await ws.fs.readFileString('/pc/home/me/a/b/c.txt'); // one call: readFile('/home/me/a/b/c.txt')Every operation on a path inside it (stat, readdir, read, write, mkdir -p,
rename, unlink, realpath) is then one call with the mount-relative path. The
namespace stats no directory on the way, checks no parent, and reads no link
inside the mount. The backend follows its own links within its own tree and
answers for every component itself: a device may refuse a stat of the
directories above the one its user consented to (EACCES) and still serve the
files in it, and a backend may make a write’s missing parents. .. inside
the mount is lexical. The namespace still owns the way in: root links that
lead to the mount, ENXIO with absentReason while the source answers null,
the mount point itself (EBUSY, EISDIR, mkdir -p has nothing to do), EROFS
under readOnly, EXDEV across mounts (so mv and ws.fs.move copy), and
any mount nested inside it (the way to one is looked up and searched here,
since the backend never sees that path). Permissions inside the mount are
the backend’s: a view’s credential reaches it through its as. readlink
answers a link’s text as written; where the namespace follows a link
itself (a node program’s staged view and data plan, a process’s walks, the
shell’s realpath and readlink -f), CompositeVFS.linkLeadsTo re-roots
an absolute target at the mount point, as the backend reads it, and answers
null when a mount nested in it covers that name (the backend follows the
link to a file the namespace cannot name: hand the link’s own path to the
namespace); a process’s bridge answers the same as linkLeadsTo. A WASI program (python3, ruby, the shell’s
wasm commands) reaches the mount through a preopen the same way, one call
per lookup, when the preopen holds the mount point; beneath a preopen
inside the mount every component is looked up, so the backend cannot
follow a link out of it.
A node process’s synchronous fs and require see a mount, one without a
sync face included, where its launch names it: its working directory, its
program’s directory and arguments, the literal paths its code names, and the
files its module map was read from. A directory named there is listed whole,
breadth first, up to 8192 names per launch (MOUNT_LIST_NAME_LIMIT), and so
is every directory from the mount point down to it. A mount the launch does
not name is not walked. A synchronous call on a mounted path the launch did
not list, or past the bound, answers EAGAIN (“/shared is an asynchronous
mount; this caller cannot wait for it”) and names the fs.promises form that
reads it; a missing name in a listed directory is ENOENT. On a mount with
resolvesPaths, a directory whose parent the backend will not list is
stat-ed and listed on its own, so a node program in a device’s consented
directory reads it synchronously too. The process holds the mount’s names
and content as of its launch, plus its own writes, which reach the mount
through its write-back.
The hosted runtime in your Durable Object
Section titled “The hosted runtime in your Durable Object”composeHostedRuntime from @nimbus-sh/worker/workspace-host adds the
terminal, resident processes, ports and the programmatic API on top of a
NimbusWorkspace in your own object. It runs its background work as
tasks your object schedules through lifecycle:
| Task | What it does |
|---|---|
resident-launch |
Boots the next chunk of a resident process’s launch. Each chunk needs its own CPU budget. |
resident-keepalive |
No work. The invocation itself keeps the object in memory, and with it the resident processes it hosts. It re-arms while a resident runs and a client is present: a hibernatable WebSocket on ctx, or a runtime call within the last 60 s. |
log-flush |
Persists buffered process output. |
log-janitor |
Drops the logs of processes whose retention has ended, 10 minutes after each exit, including logs an earlier instance persisted. It is scheduled only for the next such deadline: a running process holds none, so an idle object has nothing sooner. |
lifecycle.schedule(task, at) must make runtime.onScheduled(task) run in
a new invocation, a Durable Object alarm, at or after at. It replaces any
pending deadline for that task and rejects when it cannot arm one.
lifecycle.cancel(task) drops the deadline. Do not run onScheduled from
setTimeout in the current invocation. That timer shares the current
invocation’s CPU budget, so paced launches run out of CPU, and it is not an
event, so the platform evicts the object after about 10 s idle and its
resident processes are lost: the next instance starts them again. And while
a timer is pending the object cannot hibernate, so a log-janitor deadline
ten minutes out holds an otherwise idle object in memory until it fires.
One alarm serves every task:
type Task = Parameters<HostedRuntimeOptions['lifecycle']['schedule']>[0];
export class Workspace extends DurableObject { private schedule = (task: Task, at: number) => this.ctx.storage.transaction(async (txn) => { const tasks = { ...(await txn.get<Record<string, number>>('tasks')), [task]: at }; await txn.put('tasks', tasks); await txn.setAlarm(Math.min(...Object.values(tasks))); });
private cancel = (task: Task) => this.ctx.storage.transaction(async (txn) => { const { [task]: _, ...tasks } = (await txn.get<Record<string, number>>('tasks')) ?? {}; await txn.put('tasks', tasks); const next = Object.values(tasks); if (next.length > 0) await txn.setAlarm(Math.min(...next)); else await txn.deleteAlarm(); });
// composeHostedRuntime({ ..., lifecycle: { // waitUntil: (p) => this.ctx.waitUntil(p), schedule: this.schedule, cancel: this.cancel } })
async alarm() { const tasks = (await this.ctx.storage.get<Record<string, number>>('tasks')) ?? {}; for (const [task, at] of Object.entries(tasks)) { if (at > Date.now()) continue; await this.cancel(task as Task); await (await this.runtime()).onScheduled(task as Task); } }}onScheduled re-arms a task through schedule when it needs another turn,
so cancel a task before running it, as above.
Runtime packages
Section titled “Runtime packages”The wasm runtimes are separate npm packages, so a filesystem-only embedder never downloads a Python interpreter:
npm install @nimbus-sh/runtime-bash @nimbus-sh/runtime-cpythonimport bash from '@nimbus-sh/runtime-bash';import cpython from '@nimbus-sh/runtime-cpython';import { localFacetHost } from '@nimbus-sh/core';
const ws = await NimbusWorkspace.create({ sql, transactions, generation: 1, facets: localFacetHost(), runtimes: [bash, cpython],});
await ws.exec('bash -c "echo $((6*7))"'); // GNU bash 5.2, real BusyBox childrenawait ws.exec('python -c "print(6*7)"'); // CPython 3.13 with the real stdlib@nimbus-sh/runtime-ruby (Ruby 3.3) and @nimbus-sh/runtime-clang
(clang → wasm32-wasi) work the same way. Every package carries the same
manifests and sha256-verified blobs the hosted product serves from R2.
A runtime package is { manifest, readBlob(file) } (RuntimePackage).
readBlob returns the file’s bytes or a ReadableStream<Uint8Array> of them.
Each blob is hashed as it is written, so a stream lets an install hold
512 KiB pieces instead of whole interpreters. A blob
whose digest does not match never reaches its install path.
localFacetHost() covers bun and node only. On workerd the CSP forbids
request-time WebAssembly.instantiate. Wasm rides the Worker Loader
module map instead, which is the machinery in @nimbus-sh/worker and
@nimbus-sh/fabric. The shell, the coreutils, and the filesystem do not
need it.
Each facet localFacetHost() opens is a realm of its own: a worker thread
under Node, as each inline node run is, and a child process under Bun,
because Bun 1.4 cannot end a worker that is running WebAssembly. So nothing a
program reaches through it is your process’s: Ruby’s js bridge (JS.eval,
JS.global) sees the facet’s globals, not yours, and none of your
environment variables. A kill, Ctrl-C or signal ends the program, answering
130, and its thread or process with it (under Bun, every process it started
that stayed in its process group too), so nothing keeps spinning. A facet’s timeoutMs ends it too, and an idle facet
does not keep your process alive. Each wasm program run starts about 60 ms
later under Bun and 40 ms later under Node than it did in your own realm,
and each filesystem syscall costs about 55 µs more under Bun and 30 µs under
Node.
The Durable Object machinery
Section titled “The Durable Object machinery”@nimbus-sh/fabric is the Cloudflare half as a standalone library, for
building your own thing on Durable Objects rather than embedding Nimbus.
It holds the resident-process fabric, warm Worker Loader pools, the
multi-reason alarm multiplexer, instance-reset detection, the durable
launch journal, and byte-accounted turn pacing. Its
README
has the measured platform limits: DO storage, CPU, facet, and RPC ceilings.
For the full hosted product shape (sessions, terminal, ports, auth), use
create-nimbus-app instead of composing these by hand.