Skip to Content
ReferenceModules

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 browser runtime, the CLI, or your own embedder) decides what each name means.

A module is one of two flavors. Both use the same import syntax, and the host’s resolver picks the flavor per spec.

FlavorWhat it is
Code moduleA .py file. Its top level runs once at startup, and its exports live on a module object shared by every importer.
Native moduleA .wasm plugin over the WASM module ABI, a native .so plugin loaded by the CLI, or bindings a host registers through the Resolver trait.

Syntax

from json import dumps, loads # bare name, resolved through packages.json or the defaults from "./lib/helpers.py" import slugify # quoted path, resolved against the importing file from "https://example.com/math.wasm" import add # quoted URL from math import sqrt as root # alias with as import math # binds the module itself, use math.sqrt(2.0) from utils import * # every export becomes a flat name in scope

Name lists can span lines inside parentheses, with an optional trailing comma.

Every bare name must be declared in packages.json or be one of the official defaults. Quoted specs need no manifest. Three forms do not work: from . import x (use a quoted relative path), dotted paths like import a.b.c (parses, but nothing is auto-discovered, so declare each spec yourself), and dynamic imports (no __import__, 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.

import math import math as m print(math is m) print(import_module("math") is m) print(__name__)
Output · expected
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.

packages.json

Bare names resolve through packages.json, the only manifest name. All fields are optional.

{ "imports": { "utils": "./lib/utils.py", "fastmath": "./vendor/fastmath.wasm" }, "extends": "..", "host": { "dom": "./dom/index.js" } }
  • imports: bare name to spec (path or URL).
  • extends: a directory whose packages.json is 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.
  • host: name to JS module URL, for host libraries that run on the browser’s main thread. The compiler folds each name into the import table as a main-thread spec. Loading the JS is the runtime’s job.
  • Unknown keys are ignored. Values must be strings or objects of strings. Numbers, arrays, and booleans are rejected. Supported string escapes are \", \\, \/, \n, \t, \r. \uXXXX is not supported, so paste UTF-8 literally.

Resolution follows four rules.

  1. Walk-up. A bare name is resolved against the nearest packages.json, walking up from the importing file’s directory. Each manifest is a package boundary, the same pattern as Node’s node_modules discovery. The chain is capped at 32 hops.
  2. Hermetic. The nearest manifest wins. If it does not declare the name and has no extends, compilation fails. A deep dependency cannot borrow a parent’s aliases.
  3. Relative to the importer. A quoted relative spec resolves against the file that contains the import, so a transitively imported lib/a.py doing from "./b.py" import g finds lib/b.py.
  4. Spec shapes. A spec containing :// or starting with / is used as is. A spec starting with ./ or ../ is joined against the importer’s directory. Anything else is a bare name for the walk-up.

Integrity

Append #sha256-<64 hex chars> to a quoted spec to pin its content:

from "https://example.com/utils.py#sha256-deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567" import normalize

The compiler asks the host for the raw bytes, hashes them, and refuses to compile on a mismatch. The diagnostic shows both digests:

error: integrity check failed for './utils.py' expected sha256-0000000000000000000000000000000000000000000000000000000000000000 got sha256-36e4838513e46116f258c86b494eaa826d64fa0a9abdf36e8720a31b3d2862e2

Only sha256 is supported. Other prefixes fail with unrecognized integrity fragment. Verification lives in the compiler, so every host enforces it the same way. A host that cannot supply raw bytes fails cleanly instead of skipping the check.

The browser runtime additionally caches every fetched module in IndexedDB, in a cas store (hash to bytes) and a lockfile store (spec to hash). Repeat runs make no network requests. If a locked URL later serves different bytes, the run fails with an integrity drift error showing both digests. clearCache() on the worker wipes both stores. The CLI’s native engine does the same on disk, see the native engine.

Resolution errors

A bad import is a compile-time diagnostic with the statement’s source position, never a catchable runtime exception:

error: alias 'utils' not declared in './packages.json' --> main.py:1:6 | 1 | from utils import f | ^^^^^ error: module 'json' has no export 'badname' --> main.py:2:6

This 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.

Standard packages and host libraries

The official libraries each have their own page. Standard packages:

Host libraries:

Defaults

The browser runtime and the CLI both resolve the official names by bare name with no packages.json at all: the standard packages json, re, math, struct, test and the host libraries dom, network, storage, time. Three rules:

  • Lazy. A default is fetched only when a run actually imports it.
  • Overridable. Your packages.json, imports, or hostModules entry wins for the same name, so you can pin a version or URL.
  • Opt-out. Pass defaults: false to createWorker to disable the defaults entirely.

Defaults are a feature of these two runtimes, not of the compiler. compiler.wasm stays hermetic and resolves bare names only through the manifest the host provides. The prebuilt assets live at https://cdn.edgepython.com/std/<name>.wasm (test ships as test.py, dom as a facade at host/dom/entry.py) and https://cdn.edgepython.com/host/<name>/index.js.

The <edge-python> element

The declarative alternative to createWorker. Include the script, drop a tag, and a .py file runs.

<script type="module" src="https://cdn.edgepython.com/runtime/src/element.js"></script> <edge-python entry="./app/main.py" packages="./app/packages.json"></edge-python>

Importing element.js auto-registers the tag. On connect the element reads its attributes and the manifest, spawns the worker, runs entry if present, then fires a ready event. After ready it publishes the worker on el.worker, so the full programmatic API drives the same VM. Modules load lazily, only what a run actually imports is fetched.

AttributeDescription
entryOptional URL of a .py file to run on connect, resolved against the document. Omit it to drive the worker with el.worker.run().
packagesOptional packages.json URL. One manifest drives both directions, host for main-thread libraries and imports for worker-side modules.
wasmOptional absolute compiler.wasm URL, for self-hosting or pinning a build. Defaults to the CDN.

Where customElements is absent (Node, Deno, SSR), append ?setElement=false to the script URL and register manually with the exported defineElement(tag). When the runtime is served cross-origin, the worker spawns from a same-origin Blob URL that imports the cross-origin module, because Chromium rejects new Worker() on a cross-origin URL.

Writing your own modules

Four delivery paths, by decreasing reach:

PathDistributionBinding languageWhat it can see
CDN wasmPublish a .wasm, any host loads it by URLRust with wasm-pdk, or Zig, C, AssemblyScriptTransit values only
Native pluginA .so or .dylib the CLI loads in-processRust, the same crate as the wasm buildTransit values only
Host capabilityA custom compiler.wasm plus a matching host runtimeRust, or any wasm32 target, inside the embedderTransit values plus host services (DOM, FS, crypto)
JS host modulePlain ESM on the browser main threadJavaScriptTransit values plus window and document

Transit values are None, bool, int (128-bit), float, str, bytes, and nested list / dict. The exact wire tags are in the WASM module ABI.

CDN wasm

The contract is the WASM module 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 is an ordinary import:

from "https://example.com/slugify_mod.wasm" import slugify print(slugify("Hello World"))

One source gives two builds. The same crate compiles to .wasm for CDN distribution and to a native plugin for the CLI.

Native plugin

The CLI’s native engine loads .so (Linux) and .dylib (macOS) plugins with dlopen. A plugin exports the same ABI surface as the wasm build and leaves the six env imports undefined. The CLI supplies them itself, re-exporting its edge_* bridge symbols through -rdynamic so dlopen binds the plugin straight to the engine. The loader reads __edge_abi_version and refuses a version mismatch, a check the browser shim skips because every loader currently targets version 1. The engine is single-threaded by contract: the bridge keeps its handles and the live VM pointer in process-wide statics, so the VM and every edge_* call must stay on one thread.

Build any std package as a native plugin with cargo build --profile native. The profile inherits release and raises opt-level to 3.

Host capability

Some work cannot live in a CDN module because it happens outside the WASM sandbox. A .wasm plugin sees only the six sealed env imports and has no channel to the host. A host capability closes that gap: you ship a custom compiler.wasm that declares additional env imports, plus a host runtime that implements them. The scripts import the capability as an ordinary native module.

This is the pattern print and input already use: print calls the embedder’s host_print import. A browser distribution can register a dom module whose operations bridge to JS through its private imports. A WASI distribution can register fs against wasi_snapshot_preview1.

It is a distribution pattern, not a third module flavor. Scripts still see code modules and native modules. The public language surface and the plugin ABI stay untouched, and vanilla compiler.wasm keeps working for everyone who does not load your runtime.

JS host module

To reach main-thread browser surface (DOM, dialogs, FileReader, observers) without shipping a custom compiler, ship the capability as plain JavaScript. A module is a factory (ctx) => handlers, or a plain {name: handler} object. The factory receives { pushEvent }, which async callbacks use to wake a paused receive(). Each call is decoded in the worker, shipped to the main thread, executed, and encoded back. A handler that returns a Promise runs concurrently with other coroutines under gather, and a rejection raises a catchable exception in the calling coroutine only.

// dom.js export const dom = ({ pushEvent }) => { const nodes = []; const alloc = (n) => { nodes.push(n); return nodes.length - 1; }; return { query: (sel) => alloc(document.querySelector(sel)), set_text: (h, txt) => { nodes[h].textContent = txt; }, bind_event: (h, type, msg) => { nodes[h].addEventListener(type, (e) => pushEvent(JSON.stringify({ msg, type: e.type }))); }, }; };
<script type="module"> import { createWorker } from "https://cdn.edgepython.com/runtime/src/index.js"; import { dom } from "./dom.js"; const worker = await createWorker({ wasmUrl: "https://cdn.edgepython.com/compiler.wasm", mainThreadModules: { dom }, }); await worker.run(await (await fetch("./script.py")).text()); </script>

Handlers take decoded JS values and return plain JS values. Opaque objects like DOM nodes model as integer IDs into a registry the handlers own, the alloc pattern above. The per-call cost is a postMessage round trip, invisible at UI rate. The official host libraries are reference implementations.

The native engine

edge run, edge repl, and edge test execute in-process by default. No browser, no server, millisecond startup. The --web flag hosts the browser runtime in headless Chromium for scripts that need it. Browser-only modules fail fast: import dom and import storage are compile-time errors reading module 'dom' requires the web runtime (run with --web), and a parked frame() reports the same at run time. Everything runs under the sandbox limits with a real wall clock, so sleep() and timeouts wait in real time.

Module resolution

Quoted paths load from disk relative to the importing file. Bare names go through the packages.json walk-up. URL specs download once into ~/.cache/edge-native ($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 .so or .dylib target loads as a native plugin. The official std .wasm specs that edge add writes swap transparently to their .so twins, so one manifest serves both engines. With no manifest entry, json, re, math, and struct default to the CDN .so for the host architecture and test to the CDN test.py. Set EDGE_STD_DIR to a local checkout to serve these from disk instead.

Two modules are compiled into the binary. time carries the clocks and the calendar functions, always UTC (there is no timezone database, so tzname() is "UTC"). network exposes a blocking fetch(url, options_json?) returning {id, ok, status, headers, body}, plus fetch_text and fetch_json. A manifest entry with the same name always wins over a built-in.

Run flags

FlagEffect
--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.

These flags are native-only and reject --web. Without --save-state, a script parked on a wait the engine cannot serve prints an error and exits 1.

std packages as native libraries

Each tagged release attaches the std packages as native plugins for aarch64 and x86_64 (json-x86_64.so, json-aarch64.dylib, and so on), and the CDN serves the same assets under https://cdn.edgepython.com/native/<contract>/, where <contract> is the runtime contract version (0.1.0 today). They are the same crates shipped as .wasm plugins, compiled for the host platform with a glibc 2.17 floor on Linux. That floor is why Linux ships one build instead of a static fallback: a fully static binary links musl’s stub dlopen, which always fails. The installer checks your glibc and points at cargo install --path cli when the host is too old.

Building from source

cargo clippy --lib --features native # lint the engine module cd cli && cargo build --release # the CLI embeds the native engine cd std/json && cargo build --profile native # any std package as a native plugin

See also

  • WASM module ABI: the wire contract behind .wasm and native plugins.
  • CLI: edge add writes manifest entries, edge build vendors packages for offline use.
  • Limits and errors: the sandbox profile both engines run under.
Last updated on