Workspace files
Every VM has a workspace: the /workspace directory inside Linux. Your app reaches it directly through vm.workspace, without running a command. Use it to put files in place before a program runs, to read the results afterwards, and to build file trees and editors on the same files your Linux programs see.
Programs start in /workspace by default and HOME points there too, so a file you write as notes.txt is the notes.txt a shell in the VM sees.
Workspace paths are relative to /workspace, with or without a leading slash:
In vm.workspace |
In Linux |
|---|---|
"notes.txt" or "/notes.txt" |
/workspace/notes.txt |
"src/main.ts" |
/workspace/src/main.ts |
"/", the default for list and entries |
/workspace |
Paths can’t leave the workspace. A . or .. segment, a backslash or a NUL character is rejected with INVALID_ARGUMENT, and symbolic links can’t lead outside either. A single name can be at most 255 bytes long and a whole path at most 4095 bytes. Paths the SDK returns always start with a slash, such as /src/main.ts.
The cwd and arguments of commands are different: they are ordinary Linux paths, such as /workspace/src.
Read and write files
Section titled “Read and write files”| Method | What it does |
|---|---|
writeText(path, text, signal?) |
Writes a UTF-8 text file. |
readText(path, signal?) |
Reads a file as UTF-8 text. |
writeFile(path, bytes, signal?) |
Writes bytes. The SDK copies them, so you can reuse your buffer at once. |
readFile(path, signal?) |
Reads a file’s bytes. |
list(path?, signal?) |
Lists one directory as { kind, name, path } entries, sorted by name. kind is "file", "directory" or "symlink". |
createDirectory(path, options?) |
Creates a directory. Create it before you write files into it. |
rename(from, to, options?) |
Renames or moves a file or directory within the workspace. |
remove(path, options?) |
Removes a file. Pass { recursive: true } to remove a directory and everything in it. |
Writes resolve once the change is committed, so a command you start next sees it. Each method also takes an optional AbortSignal, directly or as options.signal.
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", runtime: { hosting: "frame" } });try { await vm.workspace.createDirectory("demo"); await vm.workspace.writeText("demo/names.txt", "carol\nalice\nbob\n");
const result = await vm.exec({ command: "/bin/sh", args: ["-c", "sort names.txt > sorted.txt"], cwd: "/workspace/demo", }); if (result.exit.kind === "exited" && result.exit.code === 0) { console.log(await vm.workspace.readText("demo/sorted.txt")); // alice, bob and carol, one per line }
for (const entry of await vm.workspace.list("demo")) { console.log(entry.kind, entry.path); // file /demo/names.txt, then file /demo/sorted.txt }} finally { await vm.dispose();}Versioned operations for editors
Section titled “Versioned operations for editors”An editor needs more than plain reads and writes. It has to notice when a build or a shell changed the file it has open, and it must not overwrite those changes by accident. The versioned methods describe each file with a WorkspaceFileEntry:
| Field | Meaning |
|---|---|
kind |
"file", "directory" or "symlink" |
name |
The entry’s name |
path |
Its workspace path, such as /src/main.ts |
size |
Its size in bytes |
version |
48 hex digits that change whenever the content changes |
link |
For a symlink, { kind, path }: whether it points to a "file", a "directory" or nothing ("missing"), and the resolved path. Otherwise null. |
| Method | What it does |
|---|---|
entries(path?, signal?) |
Lists a directory, like list, with full entries. |
stat(path, signal?) |
Returns the entry for one path. |
read(path, { maxBytes?, signal? }) |
Returns { entry, data }. maxBytes defaults to 16 MiB, which is also the most it accepts. A larger file fails with "too-large". |
write(path, data, { ifVersion?, signal? }) |
Writes at most 16 MiB of bytes and returns the file’s new entry. |
ifVersion makes a write conditional:
- Leave it out to write in any case.
- Pass a
versionto write only if the file still has that version. If it changed, the write fails withdetail.reason"version-mismatch"and the file is left as it was. - Pass
nullto create the file only if it doesn’t exist yet. If it does, the write fails with"exists".
Both failures are VMErrors with the code STORAGE_ERROR. This editor seeds a file on the first visit, keeps the version it opened and saves only over that version:
import { createVM, VMError, type WorkspaceFileEntry } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", workspaceId: "editor", runtime: { hosting: "frame" },});const encoder = new TextEncoder();
function reason(error: unknown): unknown { return error instanceof VMError ? error.detail?.["reason"] : undefined;}
// First visit only: create the file. Later visits keep the user's edits.try { await vm.workspace.write("notes.md", encoder.encode("# Notes\n"), { ifVersion: null });} catch (error: unknown) { if (reason(error) !== "exists") throw error;}
// Open: keep the entry, and with it the version you loaded.const opened = await vm.workspace.read("notes.md", { maxBytes: 1024 * 1024 });let loaded: WorkspaceFileEntry = opened.entry;console.log(new TextDecoder().decode(opened.data));
// Save: write only over the version you loaded.export async function save(text: string): Promise<"saved" | "changed-elsewhere"> { try { loaded = await vm.workspace.write("notes.md", encoder.encode(text), { ifVersion: loaded.version }); return "saved"; } catch (error: unknown) { if (reason(error) === "version-mismatch") return "changed-elsewhere"; throw error; }}On "changed-elsewhere", read the file again and let the user choose which version to keep.
Watch for changes
Section titled “Watch for changes”watch(onChange, { signal }) returns a WorkspaceWatch right away. Give it the directories to follow with setDirectories, and it calls onChange with batches of changes until you abort signal. Each change is one of:
{ kind: "changed", directory, name }: the entrynamein the watcheddirectorywas created, changed or removed.{ kind: "rescan" }: the watch can’t name every change, for example after a burst of them. Read all watched directories again.
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", workspaceId: "editor", runtime: { hosting: "frame" },});const watched = ["/"]; // add each folder your file tree has open
async function reload(directory: string): Promise<void> { const entries = await vm.workspace.entries(directory); console.log(directory, entries.map((entry) => entry.name));}
const stop = new AbortController();const watch = vm.workspace.watch( (changes) => { const stale = new Set<string>(); for (const change of changes) { if (change.kind === "rescan") watched.forEach((directory) => stale.add(directory)); else stale.add(change.directory); } for (const directory of stale) reload(directory).catch(console.error); }, { signal: stop.signal },);
await watch.setDirectories(watched);for (const directory of watched) await reload(directory); // read after the watch is set
// When the file tree closes:stop.abort();await watch.done;A watch reports the entries directly inside each watched directory, not deeper, so pass every directory your tree shows. setDirectories replaces the whole list; a VM watches at most 256 directories across all its watches. Read the directories after setDirectories resolves, so no change falls between your listing and the watch. done resolves when you abort the signal and rejects if the watch fails, for example when the VM stops.
Keep files between visits
Section titled “Keep files between visits”Without a workspaceId, the workspace is ephemeral: each createVM call gets a fresh one, and it is deleted when the VM is disposed. With a workspaceId, the files are stored in the browser, and a later createVM with the same ID, on a later visit to your app, opens them again. An ID is 1 to 128 letters, digits, _ or -.
import { createVM } from "@oxidevm/sdk";
const vm = await createVM({ mode: "userspace", workspaceId: "my-notes", runtime: { hosting: "frame" },});console.log(vm.workspace.persistence);// for example { kind: "persistent", backend: "opfs", browserRetention: "best-effort" }vm.workspace.persistence tells you what you got. It is { kind: "ephemeral" }, or { kind: "persistent", backend, browserRetention }, where backend is "opfs" (the origin private file system) or "indexeddb". browserRetention is "granted" when the browser has agreed to keep your origin’s data (see navigator.storage.persist()) and "best-effort" otherwise, when it may clear the data under storage pressure. Even granted storage can be lost when the user clears site data.
The workspace keeps files, not running programs. To bring back memory and processes too, use restore points.
Await vm.dispose() when the user is done: it saves pending workspace changes and releases the workspace. Calling dispose() from a pagehide handler starts that work, but the browser may end the page before it finishes.
One live VM per workspace
Section titled “One live VM per workspace”Only one VM at a time can open a given workspaceId, in this tab or any other tab of your app. A second createVM with the same ID fails with STORAGE_ERROR and detail.reason "workspace-in-use" until the first VM is disposed. To move a workspace between tabs, the holder opts in with vm.allowWorkspaceHandoff and the other tab calls requestWorkspaceHandoff; see the VM reference.
When a durable Userspace workspace opens on a different image, the SDK carries the user’s changes onto the new image where it can and reports the outcome in vm.start.workspaceDetails. Its state is "in-sync", "reconciled" or "conflict-preserved", and conflicts lists the paths it couldn’t carry over cleanly.
Errors
Section titled “Errors”Workspace methods reject with a VMError. Check code, and detail.reason where there is one:
code |
detail.reason |
When |
|---|---|---|
STORAGE_NOT_FOUND |
The path doesn’t exist. | |
STORAGE_ERROR |
"version-mismatch" |
A write with an ifVersion found another version. |
STORAGE_ERROR |
"exists" |
A write with ifVersion: null found the file already there. |
STORAGE_ERROR |
"too-large" |
A read hit maxBytes, or a write had more than 16 MiB. detail.size holds the size. |
STORAGE_ERROR |
"not-a-file" |
The path isn’t a regular file, for example it’s a directory. |
STORAGE_ERROR |
"not-a-directory" |
A path that has to be a directory isn’t one. |
STORAGE_ERROR |
"busy" |
Linux kept the file busy; the SDK retried for a while first. |
STORAGE_ERROR |
"workspace-in-use" |
createVM asked for a workspaceId that another live VM holds. |
PERMISSION_DENIED |
Linux file permissions don’t allow it. | |
INVALID_ARGUMENT |
The path is invalid, for example it contains ... |
|
FEATURE_NOT_AVAILABLE |
"image-update-required" |
A System image whose guest integration has no file operations. |
FEATURE_NOT_AVAILABLE |
"no-root-filesystem" |
A Userspace VM without a root filesystem. |
PROTOCOL_ERROR |
The VM has been disposed. |
The full list of codes is in Errors, and every method is in the workspace reference.