Filesystem
You can add filesystem capabilities to a component with the wasi:filesystem interface.
This guide walks through adding filesystem access to a wasmCloud TypeScript component, starting from the http-hello-world-hono template. You can use this guide as a model for implementing wasi:filesystem in your own components.
Overview
WebAssembly components run in a sandboxed environment with no default access to the host filesystem. The wasi:filesystem interface provides secure, capability-based filesystem access through preopens: directories that are explicitly mounted into a component before instantiation.
This is different from key-value and blob storage: instead of storing data in an abstract store, your component reads and writes files in mounted directories, just as a traditional application would. This makes wasi:filesystem particularly useful for:
- Serving static assets (HTML, CSS, images)
- Reading data files
- Writing logs or output files
- Any workload that needs to interact with files on disk
Components have no filesystem access by default. Directories must be explicitly mounted via volume configuration in wash dev or via volumeMounts in a WorkloadDeployment manifest. A component can only access the directories that have been preopened for it—it cannot traverse outside those mount points. For details on deploying with volumes, see Filesystems and Volumes.
Our changes will focus on two files:
wit/world.wit— Declare the new WIT importssrc/component.ts— Use the imported interfaces in application code
No changes are needed to rolldown.config.mjs, tsconfig.json, package.json, or any other file.
Step 1: Declare the WIT imports in wit/world.wit
Every capability your component uses must be declared in its WIT world. Open wit/world.wit and add import lines for the filesystem interfaces:
package wasmcloud:templates@0.1.0;
world typescript-http-hello-world-hono {
import wasi:filesystem/types@0.2.2;
import wasi:filesystem/preopens@0.2.2;
export wasi:http/incoming-handler@0.2.6;
}What these interfaces provide
wasi:filesystem/preopens— ProvidesgetDirectories(), which returns the set of preopened directories and their mount paths.wasi:filesystem/types— Provides theDescriptorresource for file and directory operations (read, write, list, open, create, delete) and supporting types likeDirectoryEntryStream,DescriptorFlags, andOpenFlags.
How dependency resolution works
When you run npm run build, the build pipeline calls wash wit fetch as a setup step. This resolves the WIT package references (like wasi:filesystem) and downloads their definitions into the wit/deps/ directory automatically. You don't need to manually download any WIT files.
The bundler (rolldown) is already configured with external: /wasi:.*/ in rolldown.config.mjs, which tells it to leave all wasi:* imports as external — they'll be resolved at component instantiation time, not at bundle time. This covers any new wasi: interface you add.
Step 2: Import and use the interface in TypeScript
With the WIT world updated, you can now import functions from the new interfaces in your TypeScript code. The import paths match the WIT interface names exactly.
Importing WIT interfaces
Add these imports at the top of src/component.ts:
import { getDirectories } from 'wasi:filesystem/preopens@0.2.2';The jco guest-types command generates .d.ts files for your WIT interfaces into generated/types/, and tsconfig.json references them through its types field. TypeScript resolves the bare wasi: specifier imports against these generated ambient module declarations, so you get full type checking and IDE autocompletion. At runtime, rolldown leaves these imports external (via external: /wasi:.*/) and jco componentize wires them up during Wasm component creation.
Key concepts
Preopens
The getDirectories() function returns an array of [Descriptor, string] tuples — each tuple pairs a directory descriptor with its mount path (e.g., "/data"). This is how your component discovers which directories are available:
function getPreopenDir(path: string) {
const dirs = getDirectories();
for (const [descriptor, dirPath] of dirs) {
if (dirPath === path) {
return descriptor;
}
}
return null;
}Descriptors
A Descriptor is a handle to an open file or directory. Directory descriptors (from preopens) can:
- List entries with
readDirectory(), which returns aDirectoryEntryStream - Open files with
openAt(), which returns a newDescriptorfor the file - Create directories with
createDirectoryAt()
File descriptors can:
- Read data with
read(length, offset), which returns[Uint8Array, boolean](data and end-of-file flag) - Write data with
write(buffer, offset), which returns the number of bytes written as abigint
Synchronous API
All filesystem operations are synchronous in the Jco bindings. The WebAssembly component model uses blocking calls, so these don't return Promises. Your existing synchronous Hono route handlers work without modification.
bigint for sizes and offsets
The wasi:filesystem interface uses bigint values for file sizes and offsets (mapped from WIT's u64 type). Use BigInt() to create these values:
// Read up to 1MB starting at offset 0
const [data, eof] = file.read(BigInt(1024 * 1024), BigInt(0));
// Write at offset 0
const bytesWritten = file.write(encoded, BigInt(0));Step 3: Build an HTTP file server
Here's a complete component that serves files from a preopened /data directory, with endpoints to list files, read a file, and write a file:
import { Hono } from 'hono';
import { fire } from '@bytecodealliance/jco-std/wasi/0.2.x/http/adapters/hono/server';
import { getDirectories } from 'wasi:filesystem/preopens@0.2.2';
const app = new Hono();
// Helper: find a preopened directory by its guest path
function getPreopenDir(path: string) {
const dirs = getDirectories();
for (const [descriptor, dirPath] of dirs) {
if (dirPath === path) {
return descriptor;
}
}
return null;
}
// List files in the preopened /data directory
app.get('/', (c) => {
const dir = getPreopenDir('/data');
if (!dir) {
return c.text('No /data directory available\n', 500);
}
const entries = [];
const stream = dir.readDirectory();
let entry = stream.readDirectoryEntry();
while (entry !== undefined) {
entries.push({ name: entry.name, type: entry.type });
entry = stream.readDirectoryEntry();
}
return c.json({ path: '/data', entries });
});
// Read a file from the /data directory
app.get('/read/:filename', (c) => {
const dir = getPreopenDir('/data');
if (!dir) {
return c.text('No /data directory available\n', 500);
}
const filename = c.req.param('filename');
const file = dir.openAt(
{ symlinkFollow: true },
filename,
{},
{ read: true }
);
const [data, _eof] = file.read(BigInt(1024 * 1024), BigInt(0));
const text = new TextDecoder().decode(data);
return c.text(text);
});
// Write a file to the /data directory
app.post('/write/:filename', async (c) => {
const dir = getPreopenDir('/data');
if (!dir) {
return c.text('No /data directory available\n', 500);
}
const filename = c.req.param('filename');
const body = await c.req.text();
const encoded = new TextEncoder().encode(body);
const file = dir.openAt(
{ symlinkFollow: true },
filename,
{ create: true, truncate: true },
{ write: true }
);
const bytesWritten = file.write(encoded, BigInt(0));
return c.json({ filename, bytesWritten: Number(bytesWritten) });
});
app.notFound((c) => {
return c.text('Not found\n', 404);
});
fire(app);
export { incomingHandler } from '@bytecodealliance/jco-std/wasi/0.2.x/http/adapters/hono/server';How openAt works
The openAt method opens a file relative to a directory descriptor:
dir.openAt(pathFlags, path, openFlags, descriptorFlags)| Parameter | Type | Description |
|---|---|---|
pathFlags | { symlinkFollow?: boolean } | Whether to follow symlinks |
path | string | File path relative to the directory |
openFlags | { create?: boolean, truncate?: boolean, exclusive?: boolean, directory?: boolean } | Controls file creation behavior |
descriptorFlags | { read?: boolean, write?: boolean, mutateDirectory?: boolean } | Access mode for the opened file |
Common patterns:
// Open existing file for reading
dir.openAt({ symlinkFollow: true }, 'data.txt', {}, { read: true });
// Create or overwrite a file
dir.openAt({ symlinkFollow: true }, 'output.txt', { create: true, truncate: true }, { write: true });
// Create a new file (fail if it exists)
dir.openAt({ symlinkFollow: true }, 'new.txt', { create: true, exclusive: true }, { write: true });Configure volumes for wash dev
To mount a host directory into the component during development, add a volumes entry to .wash/config.yaml:
build:
command: npm run build
component_path: dist/http_hello_world_hono.wasm
dev:
volumes:
- host_path: ./testdata
guest_path: /dataThe host_path specifies a directory on your local machine and guest_path specifies the mount path as seen by the component. The component's getDirectories() call will include an entry for /data that maps to the testdata/ directory in your project.
Create some test data:
mkdir -p testdata
echo "Hello from the filesystem!" > testdata/hello.txtBuild and verify
Build
npm run buildThis runs the full pipeline:
wash wit fetch— downloadswasi:filesystemWIT definitions intowit/deps/jco guest-types— generates TypeScript type definitions ingenerated/types/rolldown— bundles TypeScript intodist/component.js(leavingwasi:imports external)jco componentize— compilesdist/component.jsinto a.wasmcomponent
Run
npm run devThis starts wash dev, which reads the volume configuration from .wash/config.yaml and mounts the specified directory.
Test
# List files in the /data directory
curl http://localhost:8000/
# Read a file
curl http://localhost:8000/read/hello.txt
# Write a new file
curl -X POST http://localhost:8000/write/greeting.txt -d "Hello, filesystem!"
# Read the written file back
curl http://localhost:8000/read/greeting.txtExpected output:
{"path":"/data","entries":[{"name":"hello.txt","type":"regular-file"}]}
Hello from the filesystem!
{"filename":"greeting.txt","bytesWritten":18}
Hello, filesystem!
Written files persist on the host at testdata/greeting.txt — you can verify with cat testdata/greeting.txt.
Production deployment
In a production deployment, filesystem data is provided through Kubernetes Volumes. The volume is first made available to the wasmCloud host, then mounted into the component via a WorkloadDeployment manifest.
For a full walkthrough of deploying with volumes, including host deployments, WorkloadDeployment manifests, and volumeMounts configuration, see Filesystems and Volumes.
Important notes:
- Volumes are defined at the host level and mounted into components via
localResources.volumeMounts - The
mountPathin the manifest corresponds to the guest path the component uses ingetDirectories() - It is not necessary to define
wasi:filesystemunderhostInterfaces
Summary: checklist for adding filesystem access
- Add
importlines towit/world.witforwasi:filesystem/typesandwasi:filesystem/preopens. - Run
npm run buildonce sowash wit fetchdownloads the WIT definitions andjco guest-typesgenerates TypeScript types you can reference. - Import
getDirectoriesfromwasi:filesystem/preopens. TypeScript resolves the import against the generated ambient module declarations ingenerated/types/. - Use
getDirectories()to find preopened directories by path, then useDescriptormethods to read, write, and list files. - Configure volumes in
.wash/config.yamlfor local development or in a WorkloadDeployment manifest for production. - No bundler changes needed —
rolldown.config.mjsalready externalizes allwasi:*imports. - No new npm dependencies needed — WIT interfaces are provided by the runtime, not by npm packages.
API reference: wasi:filesystem operations used
| Operation | Signature | Description |
|---|---|---|
getDirectories() | () => [Descriptor, string][] | Return all preopened directories with their mount paths |
dir.readDirectory() | () => DirectoryEntryStream | Open a stream to read directory entries |
stream.readDirectoryEntry() | () => DirectoryEntry | undefined | Read the next entry (returns undefined at end) |
dir.openAt(pathFlags, path, openFlags, flags) | (...) => Descriptor | Open a file or directory relative to a descriptor |
file.read(length, offset) | (bigint, bigint) => [Uint8Array, boolean] | Read bytes from a file at offset; boolean is EOF flag |
file.write(buffer, offset) | (Uint8Array, bigint) => bigint | Write bytes to a file at offset; returns bytes written |
dir.createDirectoryAt(path) | (string) => void | Create a subdirectory |
dir.statAt(pathFlags, path) | (...) => DescriptorStat | Get file attributes (type, size, timestamps) |
dir.unlinkFileAt(path) | (string) => void | Delete a file |
dir.removeDirectoryAt(path) | (string) => void | Delete an empty directory |
Further reading
- TypeScript Language Guide — toolchain overview, HTTP patterns, framework integration, and library compatibility
- Filesystems and Volumes — deploying components with volume mounts in Kubernetes
- Key-Value Storage — persistent key-value storage for structured data
- Blob Storage — streaming blob storage for larger objects
- Language Support overview — summary of all supported languages and toolchains