Run a worker inside a Rust program. The worker is an ES module with export default { fetch }, for example the output of @openworkers/adapter-sveltekit. It runs in the same process as your Rust code, on V8 by default.
The worker calls Rust functions that you register by name, and your Rust code owns storage, databases and any other resource. There are no Cloudflare-style bindings to configure.
use openworkers_embed::Embed;
let embed = Embed::builder(std::fs::read_to_string("build/_worker.js")?)
.assets("build/assets")
.command_sync("greet", |name: String| Ok::<_, String>(format!("hello {name}")))
.build()?;The worker calls the command through its EMBED binding:
export default {
async fetch(request, env) {
return new Response(await env.EMBED.invoke("greet", "Ada"));
},
};In SvelteKit, @openworkers/adapter-sveltekit 0.6.2 or later adds platform.invoke, which calls env.EMBED.invoke:
export const load = async ({ platform }) => ({
user: await platform.invoke("get_user", { id: 42 }),
});- Arguments and results are JSON. A command that returns
Errthrows anErrorin the worker. commandtakes an async function.command_synctakes a function that does not wait.- A function that answers at once runs inside the turn of the worker's JavaScript: its time counts in the CPU limit of the request, 50 ms by default.
var(name, value)gives the worker a text variable:env.<name>infetch, andglobalThis.env.<name>, whereastro:envof@openworkers/adapter-astroreads it. A name is a JavaScript identifier;__proto__and the names of the bindings,EMBEDandASSETS, are refused.assets(dir)serves the files ofdirfor GET and HEAD requests, before the worker runs. The worker also reads them asenv.ASSETS. The names of the files are indexed at start, and a file is read on each request. A prerendered page added after start waits for a restart. The files under_app/immutable/(SvelteKit) and_astro/(Astro), at the root ofdir, are named by hash and getCache-Control: public, max-age=31536000, immutable; under a base path they get none.- The
fetchof the worker goes out over HTTP, with the client settings of the runner: a redirect is the answer and not a hop, a pause of 10 seconds from the server ends the call, and the body streams back. There is no egress filter, because the code is trusted. Thefetchfeature, on by default, bringsreqwest. - A response of the worker streams out:
Embed::fetchreturns as soon as the worker has one, and the body follows while the worker keeps running. A client that drops the body ends the stream, and a worker that the runtime stops while its body streams ends the body with an error. nova and boa drain a stream before they answer, so an endless stream never ends there.
The JavaScript engine is a feature. Exactly one is active; V8 is the default.
| Feature | Engine | Notes |
|---|---|---|
v8 |
V8 | Isolates stay warm in a pool. Serves every binding embed uses. |
nova |
Nova | A worker per request. Serves Assets and Invoke. It has no global fetch. String.prototype.replace substitutes nothing in a replacement template that does not start with $: "xy".replace(/(x)/, "[$1]") gives [$1]y. |
boa |
Boa | A worker per request, which runs the whole bundle again for each request: page / of the bench takes 45 ms, against 0.34 ms on V8. Serves Assets and Invoke, and a global fetch that reads a reply to its end before the worker gets it. Boa has no CPU limit and cannot interrupt a running script: a loop that never ends keeps its worker thread for good. The wall clock, 30 s, ends a request only between two jobs. |
jsc |
JavaScriptCore | Warm workers per thread, on openworkers-jsc (the system framework of macOS, or with the openworkers-runtime-jsc/static feature the static JavaScriptCore of oven-sh), reset between requests, under a CPU budget per request. The watchdog of JavaScriptCore does not cut native code such as a regular expression that backtracks. Serves Invoke only, so env.ASSETS does not exist: the files of assets(dir) are served by Rust before the worker runs. |
On macOS, the system JavaScriptCore runs JavaScript without its JIT unless the binary has the com.apple.security.cs.allow-jit entitlement: page / of the bench is about 5 times slower without it. To sign a binary with it:
cat > jit.entitlements <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>com.apple.security.cs.allow-jit</key><true/></dict></plist>
PLIST
codesign -s - --entitlements jit.entitlements -o runtime --force target/release/your-binaryWith the JIT, the CPU limit cuts JIT code only with polling traps. The jsc engine turns them on when the Embed is built, before its worker threads start; the build fails if JavaScriptCore already started without them. The option goes through the environment, so build the Embed early, before other threads start, or call openworkers_runtime_jsc::init_jsc with polling_traps yourself before then. Measured on page / of the bench (Apple M2, one thread): 382 us at 1 connection with the JIT, 2.1 ms without, against 333 us with V8.
cargo test --no-default-features --features nova,axum
cargo test --no-default-features --features boa,fetch,axumEmbed::fetch takes an HttpRequest and returns an HttpResponse from openworkers-core. For a web framework, enable its feature:
| Feature | Entry point |
|---|---|
actix |
backend::actix::handle, used with web::to as a default service |
hyper |
backend::hyper::handle(&embed, request), or handle_with(&embed, request, connection) |
axum |
backend::axum::router(embed) or router_with(embed, scheme), served with into_make_service_with_connect_info::<SocketAddr>() |
Each backend gives the worker its client as the OpenWorkers runner does, and as the adapters read it (Astro.clientAddress, the protocol of the URL). The headers that only the runner sets (forwarded, x-forwarded-for, -host, -port, -proto, x-real-ip, cf-connecting-ip, x-request-id, x-worker-id, x-worker-name, x-openworkers-internal, x-openworkers-depth) are removed from the request of the client. Then x-real-ip, x-forwarded-for and cf-connecting-ip get the address of the peer of the connection, and x-forwarded-proto, like the start of the URL, the scheme.
- The URL of the worker is the scheme, the host and the path with the query. The host is the Host header, else the authority of an HTTP/2 request. A host that is not a name, an IPv4 or a bracketed IPv6 address with an optional port (a path, a query or a userinfo in it), or a name whose last label is a number but that is not an IPv4 address (
256.1.1.1, whichnew URLrefuses), is answered with 400. - The peer: axum reads it from the connect info, actix from the connection.
hyper::handlehas none;handle_withtakes abackend::Connection { peer, scheme }. With no peer, the worker gets no address. - The scheme is
httpunless the host says otherwise:Scheme::Httpsinrouter_with(axum) or inhandle_with(hyper), or as app data in actix (App::new().app_data(Scheme::Https)); actix also giveshttpswhen it ends TLS itself. A request whose scheme is wrong gets a wrong URL: Astro refuses a form POST whose origin does not match. - Behind a proxy, the peer is the proxy: the worker gets the address of the proxy. To give it the address of the client, read it from the header of your proxy in your own handler, set the headers above on the
HttpRequest, and callEmbed::fetch, which passes the headers as they are.
cargo run --features axum --example server [build-dir]The build directory holds _worker.js and assets/. Without it, the example serves a small worker.
With v8, openworkers-runtime-v8 reads RUNTIME_SNAPSHOT_PATH at compile time. Set it in .cargo/config.toml of your program, as this repository does. When it starts, the runtime reads the V8 snapshot from that path: the bindings of the runtime, compiled once. An empty or missing file means no snapshot: the runtime starts slower and still works.
Write the snapshot before you run, and again after each update of openworkers-runtime-v8:
cargo run --release --bin snapshotA program that uses this crate calls openworkers_embed::snapshot::write() from a binary of its own, built with the same openworkers-runtime-v8.
- A file of
assets(dir)is read to the end before it is sent. - The worker code is trusted. On v8 there is no wall-clock limit, so a stream may live as long as its client reads it. On jsc the wall clock is the default of the runtime, 30 s, so a stream stops after 30 s. On boa nothing cuts a script that runs without end, at the top level or in a request: its thread stays busy for good. The CPU and memory limits are the defaults of the runtime.
- One worker per
Embed. Replacing the code means building a newEmbed. - On v8, one thread runs 8 requests at the same time, one per place: an isolate with its one context. A ninth request waits for a place. A stream keeps its place until the client has read it, so 8 open streams fill a thread.
- A request goes to the thread with the fewest requests in flight.
- A context is never retired, on v8 and on jsc. It keeps its globals for the life of the process.