Ever wanted to run a Cloudflare Workers-style JavaScript handler in a sandbox, on a VPS or anywhere, without Node.js, Bun, or even Docker? Kyushu lets you do exactly that.
Write a simple fetch handler, build it into a self-contained WebAssembly binary, and run it anywhere with a single CLI binary - kyu.
Important
Kyushu is an early-stage experiment. Expect breaking changes, missing features, and rough edges. Not recommended for production use.
Kyushu grew out of my experience building Juno, a platform where apps run in some sort of containers. I liked the concept, and when I tried Cloudflare Workers it clicked: a single function, sandboxed, handling HTTP, kind of what I implemented in the past but, for VPS or anywhere.
When you think about it, in an era where AI agents need safe environments to execute untrusted code, having a lightweight, self-hostable Wasm sandbox could be relevant, whether for running user-defined logic, isolating third-party code, or deploying edge-like handlers on your own infrastructure.
Plus, find it fun to try to avoid using Node or Bun. Long story short, felt like it was worth experimenting.
Kyushu has two moving parts:
The worker is a wasm32-wasip2 component that embeds a QuickJS JavaScript runtime. When you run kyu build, your TypeScript or JavaScript entry point is bundled (via Rolldown) and pre-initialized into the worker using Wizer. The resulting .wasm file contains your code, frozen in memory, ready to handle requests.
The runner (kyu run) is a Rust binary powered by Wasmtime. It loads your built worker, spins up an HTTP server, and dispatches incoming requests into the Wasm sandbox. Your JavaScript runs inside the sandbox - isolated from the host filesystem and environment, except for what you explicitly allow via config.
┌─────────────────────────────────────────┐
│ kyu run │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Wasmtime (host) │ │
│ │ │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ worker.wasm (sandbox) │ │ │
│ │ │ │ │ │
│ │ │ QuickJS + your JS code │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
▲さんかく │
HTTP request HTTP response
Workers are stateless by design. Each request runs in isolation and module-level variables do not persist between requests.
curl -fsSL https://kyushu.dev/install | bashOr download a pre-built binary from the releases page.
1. Write a worker
// src/index.ts import type { ExportedHandler } from "kyushu-types"; export default { async fetch(request) { return { status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ hello: "world" }), }; }, } satisfies ExportedHandler;
2. Build
kyu build
This produces worker/__kyushu_worker.wasm.
3. Run
kyu run
# Listening on http://0.0.0.0:5987Install the types package for autocompletion:
npm install --save-dev kyushu-types
Workers export a default object with a fetch handler:
export default { async fetch(request: WorkerRequest): Promise<WorkerResponse> { // ... }, };
| Field | Type | Description |
|---|---|---|
method |
WorkerMethod |
HTTP method (GET, POST, etc.) |
url |
string |
Full request URL |
headers |
Record<string, string> | undefined |
Request headers |
body |
string | ArrayBuffer | Uint8Array | undefined |
Request body |
| Field | Type | Description |
|---|---|---|
status |
number | undefined |
HTTP status code (default: 200) |
body |
string | ArrayBuffer | Uint8Array | undefined |
Response body |
headers |
Record<string, string> | undefined |
Response headers |
kyu build [config] Bundle and pre-initialize a worker
kyu run [config] Run a built worker
kyu dev [config] Start a local development server with hot-reload
kyu --version Print the CLI version
All commands accept an optional config file. Pass a path to override the defaults.
The available options and their defaults:
Configure the build step.
[input] src = "src/index.ts" # default [output] dir = "worker" # default file = "__kyushu_worker.wasm" # default
| Field | Type | Default | Description |
|---|---|---|---|
input.src |
string | src/index.ts |
Path to your TypeScript or JavaScript entry point |
output.dir |
string | worker |
Output directory for the built worker |
output.file |
string | __kyushu_worker.wasm |
Output filename for the built worker |
Configure the runner.
[run] wasm = "worker/__kyushu_worker.wasm" # default port = 5987 # default [[worker.mounts]] host = "." guest = "/" writable = true [[worker.env]] key = "API_KEY" value = "secret"
| Field | Type | Default | Description |
|---|---|---|---|
run.wasm |
string | worker/__kyushu_worker.wasm |
Path to the built worker .wasm file |
run.port |
number | 5987 |
Port to listen on |
worker.mounts |
array | — | Filesystem mounts to expose to the worker |
worker.mounts[].host |
string | — | Path on the host filesystem |
worker.mounts[].guest |
string | — | Path inside the worker sandbox |
worker.mounts[].writable |
bool | false |
Whether the mount is writable |
worker.env |
array | — | Environment variables to expose to the worker |
worker.env[].key |
string | — | Environment variable name |
worker.env[].value |
string | — | Environment variable value |
Configure the development server.
[dev] port = 5987 # default
| Field | Type | Default | Description |
|---|---|---|---|
dev.port |
number | 5987 |
Port to listen on |
dev.watch |
bool | true |
Watch for file changes and reload automatically |
A custom [input] or [worker] configuration can also be applied; the details are omitted for brevity but follow the same options documented above.
Caution
Kyushu's sandbox is only as strong as its dependencies. Please read this before deploying anything sensitive.
Your JavaScript runs inside a Wasmtime WebAssembly sandbox, which provides strong isolation from the host system. Access to the filesystem and environment variables is gated by explicit configuration in the run config.
However, there are important caveats:
- JavaScript polyfills: The QuickJS runtime is extended with Node.js-compatible polyfills from the wasm-rquickjs project, which implement Node.js APIs (
fs,crypto,http, etc.) inside the Wasm sandbox. Their security properties have not been reviewed by this project, and it is unknown whether they have been independently audited. They are a third-party dependency and are used as-is. - Experimental status: Kyushu itself has not been audited. The sandboxing boundaries, configuration parsing, and request handling are all early-stage code.
Use Kyushu for experimentation, local development, and learning. Do not expose it to untrusted input in production without a thorough review.
Calls to console.log and other console methods at the top level of your worker module are silently swallowed. This is a side effect of Wizer pre-initialization: writing to stdout during snapshotting corrupts internal stdio state for the runtime. Only log starting from the fetch handler, not at module scope (which is ignored).
Some npm packages use dynamic import() internally as an escape hatch to avoid bundling certain dependencies:
function importAtRuntime(specifier) { return import(specifier); }
Bundlers intentionally leave these calls untouched, and Kyushu's Wasm sandbox has no Node.js module resolution at runtime — so they'll throw a ReferenceError when executed.
Example: file-type's fromFile dynamically imports strtok3 at runtime. Use fromBuffer instead:
// ❌ const fileType = await fileTypeFromFile(filepath); // ✅ const file = await readFile(filepath); const fileType = await fileTypeFromBuffer(file);
Rule of thumb: when a package offers separate Node.js vs. browser/edge APIs, prefer the browser/edge variant.
MIT