Modules
Every import is resolved at compile time. The compiler asks the host for each module, flattens it into the bytecode, and the VM never fetches anything at run time. The host (the JS host, the CLI, or your own embedder) decides what each name means.
A module is one of two kinds, and its artifact decides which. Both use the same import syntax, and nobody classifies a package, a manifest entry only names a spec.
| Kind | Artifact | What it is |
|---|---|---|
| Code module | .py | Its top level runs once at startup, and its exports live on a module object shared by every importer. |
| Native module | .wasm | A plugin over the ABI. A host can also register bindings of this kind through the Resolver trait. |
No JavaScript loads besides the host’s own system calls, so both hosts refuse a .js or .mjs import where it is written, module 'charts' is JavaScript, ship a .py or a .wasm, and edge add, edge lock, edge build and edge publish stop on one before they write anything. Any other extension is sniffed. A file that starts with the wasm magic bytes is a native module, anything else is Python source.
Syntax
from json import dumps, loads # bare name, declared in edge.json
from .lib.helpers import slugify # relative to the importing file (./lib/helpers.py)
from ..shared.util import chunks # one dir up per extra dot (../shared/util.py)
from lib.helpers import slugify as sl # absolute from the nearest edge.json dir
import math # binds the module itself, use math.sqrt(2.0)
from utils import * # every export becomes a flat name in scopeName lists can span lines inside parentheses, with an optional trailing comma.
Dots map to directories and the .py suffix is implicit. A leading dot anchors the spec at the importing file, a dotted name anchors at the nearest edge.json directory, and a plain name must be declared in edge.json.
Nothing resolves without a manifest entry, and edge add <name> writes the entry.
Two forms do not work. from . import x names nothing, because Edge has no packages for a bare dot to reach. Dynamic imports are absent too, there is no __import__ and no importlib, and the module set is fixed per compilation.
Module semantics
Modules are singletons. The same canonical spec is fetched, parsed, and initialized once, and every importer sees the same object. Mutating a module attribute is visible to all consumers. Inside a module’s top level, __name__ is bound to its canonical spec, so an if __name__ == "__main__": block is skipped on import. A module’s helpers stay private to it: attribute access goes through the module object, not the importer’s globals.
True True __main__
import_module(name) looks up a module bound by a plain import in the current scope, so it can dispatch among modules already imported. A module pulled in only with from x import ... is not visible to it. An import cycle (a.py imports b.py imports a.py) raises RuntimeError: circular import at startup.
edge.json
Bare names resolve through edge.json, the only manifest name. Every field is optional.
{
"name": "charts",
"version": "0.1.0",
"description": "Draw a chart from a list of points.",
"repository": "https://github.com/you/charts",
"docs": "./docs",
"edge": "0.7.0",
"imports": {
"json": "0.1.0",
"utils": "./lib/utils.py",
"fastmath": "./vendor/fastmath.wasm"
},
"extends": ".."
}imports: bare name to spec (path, URL, or amajor.minor.patchversion naming a registry package). No name may be a system module’s, such astimeornet, which permissions grant instead. One map holds every module, the artifact behind each spec tells the host whether it is a code module or a native module. A version names a release and nothing else,edge.lockis what says where its bytes are.permissions: which system module the program and each package it pulls in may use, and what each may reach, asmodule:scopeentries per holder. The root grants the packages it imports and each passes on to its own, see System.extends: a directory whoseedge.jsonis consulted when a name is not declared locally. Use it for monorepo sub-packages that share the parent’s dependencies. Omit it for hermetic libraries. Cycles in the chain fail at compile time.edge: the lowest engine the project runs on,major.minor.patch. It runs on that version or any later one, never on an earlier one, so an older CLI stops withthis project needs edge 0.7.0, this is 0.6.9instead of failing on a field it cannot read.edge initwrites the version that scaffolded the project, and omitting it runs anywhere. A package declares its own in the manifest it packs, so a dependency written for a later engine is refused where it loads, by the CLI and the browser alike, rather than where it was declared.name,version,description,repository,docs: what a package carries into a registry. The compiler ignores all five, and the CLI and the registry check their shape with the same rules. A name is at most 40 lowercase letters, digits and single hyphens starting with a letter, neverall,mainoreval, which permissions reserve, nor a system module’s name, a version ismajor.minor.patchwith each part 0 to 99 and no leading zeros, a description is one line of 60 characters at most, a repository is anhttpsurl a listing can link, anddocsis a relative directory inside the project.
Only what nothing else can supply belongs here. An author and a date come from the account that publishes, and the license is read from the LICENSE file the artifact already carries, so none of the three is a field.
- Unknown keys are ignored. Values must be strings, lists of strings, or objects of them. Numbers and booleans are rejected. Supported string escapes are
\",\\,\/,\n,\t,\r.\uXXXXis not supported, so paste UTF-8 literally.
Resolution follows four rules.
- Walk-up. A bare name is resolved against the nearest
edge.json, walking up from the importing file’s directory. Each manifest is a package boundary, the same pattern as Node’snode_modulesdiscovery. The chain is capped at 32 hops. - Hermetic. The nearest manifest wins. If it does not declare the name and has no
extends, the name is a system module the host serves to that package, and compilation fails when the host serves none or refuses it. A deep dependency cannot borrow a parent’s aliases. - Relative to the importer. A leading-dot spec resolves against the file that contains the import, so a transitively imported
lib/a.pydoingfrom .b import gfindslib/b.py. - Spec shapes. A spec containing
://or starting with/is used as is. A spec starting with./or../is joined against the importer’s directory. A spec of three numeric parts is a version, which the host replaces with its locked URL before the compiler reads the manifest, so the compiler only ever sees a path or a URL. Any other spec with a/is joined against the nearestedge.jsondirectory. Anything else is a bare name for the walk-up.
edge.lock
A version says which release a project wants, and the edge.lock beside its edge.json says where that release is. edge lock writes it, every other command reads it, so a run resolves a name without asking a registry.
{
"json": {
"version": "0.1.0",
"url": "https://cdn.edgepython.com/pkg/json/0.1.0/app.edge",
"digest": "sha256-0a68e05fc4b6e9110fccafd57802cab5c2a3ca64ed1d3b486ec2770f1a8c758a"
}
}One entry per declared name, and nothing transitive, since a published package carries its own dependencies inside its .edge. An entry for a bare URL records only the URL and the digest, a relative path is absent because its bytes are already in the project, and a URL that carries its own #sha256- is absent because it already says what to expect.
A lock answers for the manifest it sits beside, so a nested package and a packed .edge each carry their own. A declared version with no entry, or one whose entry holds another release, fails before anything compiles:
error: edge.json at 'edge.json': 'json' is not locked, run edge lockIntegrity
Append #sha256-<64 hex chars> to a spec in edge.json to pin its content, which a locked version already does for you:
{ "imports": { "utils": "https://example.com/utils.py#sha256-deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567" } }from utils import normalizeThe JS host fetches the raw bytes, hashes them, and refuses to run on a mismatch. The diagnostic shows both digests:
error: integrity check failed for 'https://example.com/utils.py'
expected sha256-deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567
got sha256-36e4838513e46116f258c86b494eaa826d64fa0a9abdf36e8720a31b3d2862e2Only sha256 is supported. Other prefixes fail with unrecognized integrity fragment. The JS host checks the pin in its fetch layer. The CLI checks the pin on every module it downloads or reads from disk, .wasm plugins included.
In a browser a worker keeps every module it fetched in memory for its lifetime, so repeat runs make no network requests, and clearCache() empties it. The CLI caches on disk and refuses a download that drifts, see The CLI.
Resolution errors
A bad import is a compile-time diagnostic with the statement’s source position, never a catchable runtime exception:
error: module 'utils' is not provided by this host and no edge.json declares it
--> main.py:1:6
|
1 | from utils import f
| ^^^^^
error: module 'json' has no export 'badname'
--> main.py:2:6The first diagnostic is the same for a typo and for a package you forgot to declare. The CLI adds a help: line pointing at edge add <name> or at a relative import. It also covers modules Edge Python does not ship, like os or sys. They parse for syntactic compatibility and are then rejected here, before any code runs.
In a page
A page runs a program with createWorker, which boots the engine in a Web Worker inside a sandboxed frame of its own. The page draws itself, the program answers through what it prints and what run resolves with, and pushEvent wakes a receive().
<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. baseUrl is where its files live, and the page reads for it the edge.json there, its imports and what it grants, and each file a run imports, never outside that directory and never with the page’s cookies. A script in a subdirectory passes its path as entry, worker.run(src, { entry: "sub/main.py" }), so its relative imports resolve beside it. createWorker finds compiler.wasm beside the host, wasmUrl points it elsewhere, imports with permissions stand in for an edge.json a page does not have, and limits, such as { memory: 512, ops: 1e9 }, raises what a run may hold and spend. It needs a page and throws createWorker needs a page, missing in this runtime without one. How the frame keeps the program apart from the page is in System.
Writing your own modules
A module of your own ships as a .py, or as a .wasm plugin over the ABI written in Rust with wasm-pdk, or in Zig, C or AssemblyScript. A plugin sees transit values, and reaches the outside only through the system calls its package is granted.
Transit values are None, bool, int (128-bit), float, str, bytes, and nested list / dict. The exact wire tags are in the ABI.
CDN wasm
The contract is the ABI, language-agnostic and sealed. Rust authors use the wasm-pdk crate’s macros (#[plugin_fn], #[plugin_class], and friends) and write plain Rust. Other languages use community PDKs or hand-written wire boilerplate. The script side imports it through a manifest alias:
{ "imports": { "slugify_mod": "https://example.com/slugify_mod.wasm" } }from slugify_mod import slugify
print(slugify("Hello World"))The CLI
edge run, edge repl, edge test, and edge actor run the engine the JS host loads, a compiler.wasm built from the same source for speed, precompiled for the host machine and executed under wasmtime. No browser, no server, millisecond startup. Everything runs under the sandbox limits with a real wall clock, so sleep() and timeouts wait in real time.
Module resolution
Relative imports load from disk relative to the importing file, dotted imports from the nearest edge.json dir. Bare names go through the edge.json walk-up, nothing resolves without an entry, and a declared version goes through the edge.lock beside the manifest that declared it. Manifest URLs download once into ~/.cache/edge/modules ($XDG_CACHE_HOME is honored) with a 64 MB cap. A downloaded file is pinned by a .lock sidecar holding its SHA-256, and later runs refuse on drift until you remove the cache entry. A remote edge.json that answered 404 is remembered as absent, so later runs skip the request.
The CLI compiles a .wasm plugin from disk or a URL with Cranelift on first use and keeps the machine code in ~/.cache/edge/plugins, keyed by the file’s hash, so later runs skip the compile.
A module that fails to load fails at compile time, at the import that names it, with (via <importer>) appended when another module reached it.
| Module | Message |
|---|---|
A .so or .dylib | module 'fastmath' is not supported, ship a .wasm |
A .wasm that does not load | module 'fastmath' is not a valid plugin, <reason> |
A .js or .mjs | module 'charts' is JavaScript, ship a .py or a .wasm |
| A plugin, or a URL off the official CDN, in an eval group | module 'fastmath' is not available to untrusted eval runs |
Run flags
| Flag | Effect |
|---|---|
--events <f> | Each line of the file (or FIFO) feeds one receive(). End of input parks the script. |
--save-state <f> | On a wait the engine cannot serve, write a snapshot to the file and exit 0. |
--restore-state <f> | Boot from a snapshot instead of a script and keep running. An unreadable file exits 2. |
--preempt <n> | Yield every n loop back-edges and resume, so a program with no suspension point stays snapshottable. |
--memory <MB> | Memory the run may hold, 256 by default, see Limits. edge test and edge repl take it too. |
--ops <n> | Operations the run may spend, 100 million by default. edge test and edge repl take it too. |
Without --save-state, a script parked on a wait the engine cannot serve prints an error and exits 1.
Untrusted code
Code you do not trust goes to an eval group, where each message runs in a fresh wasm instance with a memory cap and a ten second wall-clock deadline. A bundle that carries its own edge.json resolves through it, any other message through the pool’s manifest. Either way .wasm plugins are refused, remote modules load only from https://cdn.edgepython.com/, and a bundle grants permissions only within what the pool grants eval. send() has no scheduler there.
Building from source
cargo wasm # compiler.wasm
cd cli && cargo build --release # embeds it precompiledcli/build.rs reads compiler.wasm from that path, or from EDGE_COMPILER_WASM, and fetches nothing. The system calls run in SpiderMonkey, which the first build compiles from source, see CONTRIBUTING.md for what it needs.
See also
- ABI: the wire contract behind
.wasmplugins. - CLI:
edge addwrites manifest entries,edge lockresolves them,edge buildvendors packages for offline use. - Limits and errors: the sandbox profile both hosts run under.
- System:
fs,net,time,secret, and the permissions that let a package use them.