Platforms
JavaScript
The JS host runs a program in a web page. createWorker boots the engine in a Web Worker inside a sandboxed frame of its own. Everything else is a call on the worker it returns.
Load the host
The host loads from one module on the CDN.
https://cdn.edgepython.com/js/src/index.jsImport createWorker from it and hand it the directory of the program.
<script type="module">
import { createWorker } from "https://cdn.edgepython.com/js/src/index.js";
const worker = await createWorker({ baseUrl: new URL("./app/", location.href).href });
worker.onOutput((text) => console.log(text));
const src = await fetch("./app/main.py").then((r) => r.text());
await worker.run(src);
</script>A worker runs one program. The page draws itself, and the program answers through what it prints and what run resolves with.
createWorker needs a page. In a runtime without one it throws createWorker needs a page, missing in this runtime.
| Option | Meaning |
|---|---|
baseUrl | The directory of the program. The page reads its edge.json and every file a run imports from there |
wasmUrl | Where compiler.wasm lives. By default it sits beside the host |
imports | Stands in for the imports of an edge.json the page does not have |
permissions | Stands in for its permissions |
secrets | The values secret reads, keyed by name |
limits | What a run may hold and spend, { memory, ops } |
trace | true reports every system call through onTrace |
The worker it returns carries these calls.
| Call | Does |
|---|---|
run(src, opts) | Runs src and resolves once the program ends |
pushEvent(message) | Hands a message to receive() |
onOutput(handler) | Calls handler with each piece of text the program prints |
onTrace(handler) | Calls handler with each trace event |
setPreemptInterval(n) | Makes the next run yield every n loop back-edges |
pause() | Parks the run |
resume() | Lets a parked run go again |
saveState() | Freezes the parked run to a blob |
restoreState(blob) | Boots from a blob and continues |
stateGlobals() | Reads the globals of a parked run |
stateStack() | Reads the coroutines of a parked run |
reset() | Drops the registered modules and closes every request and socket a run left open |
clearCache() | Forgets every module fetched and every manifest found missing |
dispose() | Ends the worker and removes its frame |
loadMs | How long the engine took to load, in milliseconds |
Every call returns a promise except pushEvent, the two handlers and dispose. dispose rejects every pending call with worker disposed.
Run a program
run(src, opts) takes the source as a string. The second argument is optional.
| Option | Meaning |
|---|---|
entry | The path of the script src came from. Its relative imports resolve beside it. The project root when absent |
input | The text input() reads, one line per call |
repl | Evaluates src as the next input of a REPL on the interpreter the last input left |
incremental | Reuses the engine instance. Module-level state persists across runs |
A script in a subdirectory passes its path, as in worker.run(src, { entry: "sub/main.py" }). Passing baseUrl to run rejects with baseUrl belongs to createWorker, a worker runs one program.
run resolves with { out, ms, exitCode }.
outholds the traceback when the run fails, and is empty otherwise. Printed text arrives throughonOutput.msis how long the run took, in milliseconds.exitCodeis set when the program raisedSystemExitwith an integer code.
Files and imports
The page reads for the worker the edge.json at baseUrl, its imports and what it grants, and each file a run imports.
- It never reads outside that directory.
- It never sends the cookies of the page.
- A worker keeps every module it fetched in memory for its lifetime. Repeat runs make no network requests until
clearCache()empties it. fslists the files of a page fromedge.files, a JSON list besideedge.json.edge build --webwrites it andedge serveanswers it. A page that embeds the engine serves it itself.
How an import resolves is on Modules. What fs reads is on System.
Permissions and secrets
A page with an edge.json at baseUrl takes its grants from there. A page without one passes imports and permissions to createWorker instead. The values secret reads go in secrets, and each is read only under a name the permissions grant.
const worker = await createWorker({
baseUrl: new URL("./app/", location.href).href,
imports: { calc: "./calc.py" },
permissions: { main: ["net:api.example.com", "secret:API_KEY"] },
secrets: { API_KEY: key },
});The holders and entries a permissions section takes are on System.
Limits
limits raises or lowers what a run may hold and spend.
const worker = await createWorker({ limits: { memory: 512, ops: 1e9 } });memorycaps what a run holds, in MB.opscaps how many operations it runs.- A field left out keeps the sandbox value of 256 MB and 100,000,000 operations.
- The call depth stays fixed at 256.
The old heap and calls fields are gone. createWorker throws when it meets either, naming what replaced it. Each cap and the error it raises are on Limits and errors.
Events
pushEvent(message) hands a string to the receive() queue of the running program. Any other value is turned into a string first. A message pushed before the program parks waits and arrives at its next receive().
onOutput(handler) receives each piece of text the program prints. A worker holds one handler at a time. A later call replaces it.
Pause and snapshots
A run can be paused, frozen to a blob and brought back later. What a blob holds and where a run can freeze are on Snapshots.
setPreemptInterval(n)makes the VM yield everynloop back-edges. It takes effect at the nextrunorrestoreState.0turns preemption off and is the default.pause()resolvestrueonce the run is parked. It resolvesfalsewhen no run reaches a park, because it already finished or none started.resume()lets a parked run go again.saveState()resolves with aUint8Arrayholding the whole VM. It rejects withnothing to save: the program is not pausedwhile the run executes.restoreState(blob)takes aUint8Arrayor anArrayBuffer. It resolves likerunonce the program finishes, and the run keeps the limits saved in the blob.stateGlobals()andstateStack()read a parked run without waking it.
A preempt interval lets pause() stop a program with no suspension point at all.
# counter.py, no suspension point anywhere
n = 0
while True:
n += 1
if n % 1_000_000 == 0:
print(n)const worker = await createWorker();
await worker.setPreemptInterval(50_000); // ~1 yield per 50k loop iterations
worker.run(counterSrc); // never awaited, it runs forever
await worker.pause(); // resolves true once parked, mid-loop
const blob = await worker.saveState();
await worker.stateGlobals(); // { n: "3170000" }
worker.resume(); // keeps counting from 3170000The yields are invisible. The run continues on its own, and the host only notices when it asks to stop.
Pick n for how fast a pause has to land. Each yield is one event loop round trip. Browsers clamp it to a few milliseconds.
| Interval | Yields | Cost on a tight i = i + 1 loop |
|---|---|---|
1_000 | About every millisecond of work | Several times the runtime |
1_000_000 | About once a second | Almost nothing |
Persisting a run
saveState returns opaque bytes. Any store that holds bytes keeps a blob, and none of them runs the VM.
The page below checkpoints a counter into Cache Storage every five seconds. On load it resumes the saved session when one exists and otherwise starts fresh.
<script type="module">
import { createWorker } from "https://cdn.edgepython.com/js/src/index.js";
const worker = await createWorker();
const store = await caches.open("edge-python");
const KEY = "/session";
await worker.setPreemptInterval(50000);
const hit = await store.match(KEY);
hit
? worker.restoreState(new Uint8Array(await hit.arrayBuffer()))
: worker.run(await fetch("./counter.py").then((r) => r.text()));
const checkpoint = async () => {
if (!await worker.pause()) return; // the run already finished
await store.put(KEY, new Response(await worker.saveState()));
worker.resume();
};
setInterval(checkpoint, 5000);
addEventListener("visibilitychange", () => document.hidden && checkpoint());
</script>The interval is what protects the session.
visibilitychange is the last event that reliably fires on mobile. Its handler is async, and the browser can kill the page before the write lands. The rolling checkpoint is the guarantee, and the handler only shortens what a close costs.
Other stores take the same bytes.
// A download the user keeps.
const file = new Blob([await worker.saveState()], { type: "application/octet-stream" });
const url = URL.createObjectURL(file);
Object.assign(document.createElement("a"), { href: url, download: "cart.snapshot" }).click();
URL.revokeObjectURL(url);
// A file the user picks.
worker.restoreState(new Uint8Array(await fileInput.files[0].arrayBuffer()));
// IndexedDB keeps a Uint8Array directly, a server takes the raw bytes.
await store.put("cart", await worker.saveState());
await fetch("/saves/cart", { method: "PUT", body: await worker.saveState() });A server works the same way as a local store. It only holds bytes.
const res = await fetch(`/saves/${userId}`);
if (res.ok) {
worker.restoreState(new Uint8Array(await res.arrayBuffer())); // continue the held session
} else {
worker.run(cartSrc); // no save yet, start fresh
}The sandboxed frame
Every worker lives in a sandboxed frame with an opaque origin of its own.
- A program holds none of the cookies or storage of the page.
- It cannot reach the DOM of the page, even a DOM the page did not write.
- The page fetches the engine and hands it to the frame. The frame loads no code of its own.
- The page reads the files of the program for the frame, only under
baseUrland without cookies.
The browser lets the frame connect only where the program may go.
- The origins its imports name, and where each release in its
edge.locklives. - The hosts its permissions grant, on http, https, ws and wss and any port.
A request the grants would miss never leaves the frame. A path prefix in a grant is checked by the engine, since a frame policy names only a whole host.
Trace
A worker created with trace: true reports every system call of a run through onTrace. Each event is timed in milliseconds from the start of the run.
const worker = await createWorker({ trace: true, permissions: { main: ["net:api.example.com"] } });
worker.onTrace((event) => console.log(event));
// { kind: "call", at: 2.4, ms: 181, pkg: "main", call: "net.response", scope: "", outcome: "ok", id: 1, status: 200 }kind | Fields | Sent |
|---|---|---|
run | at, epoch | First, with epoch in milliseconds since the Unix epoch |
call | at, ms, pkg, call, scope, outcome, and id, status or bytes where they apply | Once each system call settles |
print | at, text | For each print, cut at 120 characters |
sleep | at, ms | For each wait on the wall clock |
A call names the package, the call, what it reached and how it ended. outcome is ok or the name of the error.
| Module | scope holds |
|---|---|
net | The method, host and path of a request, the host and path of a socket |
secret | The name |
time | The clock |
fs | The path |
An event never carries a body, a header or a query. A secret value that shows up in a scope or a print is masked. A value of eight characters or more shows its first two, and a shorter one shows none.
A batch reports as one call. The calls inside it are not listed one by one. A worker without trace pays one check per call.