Skip to content

gh-127111: Emscripten Make web example work again #127113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions Makefile.pre.in
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,6 @@ SRCDIRS= @SRCDIRS@
# Other subdirectories
SUBDIRSTOO= Include Lib Misc

# assets for Emscripten browser builds
WASM_ASSETS_DIR=.$(prefix)
WASM_STDLIB=$(WASM_ASSETS_DIR)/lib/python$(VERSION)/os.py

# Files and directories to be distributed
CONFIGFILES= configure configure.ac acconfig.h pyconfig.h.in Makefile.pre.in
DISTFILES= README.rst ChangeLog $(CONFIGFILES)
Expand Down Expand Up @@ -737,6 +733,9 @@ build_all: check-clean-src check-app-store-compliance $(BUILDPYTHON) platform sh
build_wasm: check-clean-src $(BUILDPYTHON) platform sharedmods \
python-config checksharedmods

.PHONY: build_emscripten
build_emscripten: build_wasm web_example

# Check that the source is clean when building out of source.
.PHONY: check-clean-src
check-clean-src:
Expand Down Expand Up @@ -1016,23 +1015,38 @@ $(DLLLIBRARY) libpython$(LDVERSION).dll.a: $(LIBRARY_OBJS)
else true; \
fi

# wasm32-emscripten browser build
# wasm assets directory is relative to current build dir, e.g. "./usr/local".
# --preload-file turns a relative asset path into an absolute path.
# wasm32-emscripten browser web example

WEBEX_DIR=$(srcdir)/Tools/wasm/emscripten/web_example/
web_example/python.html: $(WEBEX_DIR)/python.html
@mkdir -p web_example
@cp $< $@

web_example/python.worker.mjs: $(WEBEX_DIR)/python.worker.mjs
@mkdir -p web_example
@cp $< $@

.PHONY: wasm_stdlib
wasm_stdlib: $(WASM_STDLIB)
$(WASM_STDLIB): $(srcdir)/Lib/*.py $(srcdir)/Lib/*/*.py \
$(srcdir)/Tools/wasm/wasm_assets.py \
web_example/server.py: $(WEBEX_DIR)/server.py
@mkdir -p web_example
@cp $< $@

WEB_STDLIB=web_example/python$(VERSION)$(ABI_THREAD).zip
$(WEB_STDLIB): $(srcdir)/Lib/*.py $(srcdir)/Lib/*/*.py \
$(WEBEX_DIR)/wasm_assets.py \
Makefile pybuilddir.txt Modules/Setup.local
$(PYTHON_FOR_BUILD) $(srcdir)/Tools/wasm/wasm_assets.py \
--buildroot . --prefix $(prefix)
$(PYTHON_FOR_BUILD) $(WEBEX_DIR)/wasm_assets.py \
--buildroot . --prefix $(prefix) -o $@

python.html: $(srcdir)/Tools/wasm/python.html python.worker.js
@cp $(srcdir)/Tools/wasm/python.html $@
web_example/python.mjs web_example/python.wasm: $(BUILDPYTHON)
@if test $(HOST_GNU_TYPE) != 'wasm32-unknown-emscripten' ; then \
echo "Can only build web_example when target is Emscripten" ;\
exit 1 ;\
fi
cp python.mjs web_example/python.mjs
cp python.wasm web_example/python.wasm

python.worker.js: $(srcdir)/Tools/wasm/python.worker.js
@cp $(srcdir)/Tools/wasm/python.worker.js $@
.PHONY: web_example
web_example: web_example/python.mjs web_example/python.worker.mjs web_example/python.html web_example/server.py $(WEB_STDLIB)

############################################################################
# Header files
Expand Down Expand Up @@ -3053,8 +3067,7 @@ clean-retain-profile: pycremoval
find build -name '*.py[co]' -exec rm -f {} ';' || true
-rm -f pybuilddir.txt
-rm -f _bootstrap_python
-rm -f python.html python*.js python.data python*.symbols python*.map
-rm -f $(WASM_STDLIB)
-rm -rf web_example python.mjs python.wasm python*.symbols python*.map
-rm -f Programs/_testembed Programs/_freeze_module
-rm -rf Python/deepfreeze
-rm -f Python/frozen_modules/*.h
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Updated the Emscripten web example to use ES6 modules and be built into a
distinct ``web_example`` subfolder.
122 changes: 86 additions & 36 deletions Tools/wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ https://github.com/psf/webassembly for more information.

To cross compile to the ``wasm32-emscripten`` platform you need
[the Emscripten compiler toolchain](https://emscripten.org/),
a Python interpreter, and an installation of Node version 18 or newer. Emscripten
version 3.1.42 or newer is recommended. All commands below are relative to a checkout
of the Python repository.
a Python interpreter, and an installation of Node version 18 or newer.
Emscripten version 3.1.73 or newer is recommended. All commands below are
relative to a checkout of the Python repository.

#### Install [the Emscripten compiler toolchain](https://emscripten.org/docs/getting_started/downloads.html)

Expand All @@ -50,7 +50,7 @@ sourced. Otherwise the source script removes the environment variable.
export EM_COMPILER_WRAPPER=ccache
```

### Compile and build Python interpreter
#### Compile and build Python interpreter

You can use `python Tools/wasm/emscripten` to compile and build targetting
Emscripten. You can do everything at once with:
Expand All @@ -70,6 +70,88 @@ instance, to do a debug build, you can use:
python Tools/wasm/emscripten build --with-py-debug
```

### Running from node

If you want to run the normal Python CLI, you can use `python.sh`. It takes the
same options as the normal Python CLI entrypoint, though the REPL does not
function and will crash.

`python.sh` invokes `node_entry.mjs` which imports the Emscripten module for the
Python process and starts it up with the appropriate settings. If you wish to
make a node application that "embeds" the interpreter instead of acting like the
CLI you will need to write your own alternative to `node_entry.mjs`.


### The Web Example

When building for Emscripten, the web example will be built automatically. It is
in the ``web_example`` directory. To run the web example, ``cd`` into the
``web_example`` directory, then run ``python server.py``. This will start a web
server; you can then visit ``http://localhost:8000/python.html`` in a browser to
see a simple REPL example.

The web example relies on a bug fix in Emscripten version 3.1.73 so if you build
with earlier versions of Emscripten it may not work. The web example uses
``SharedArrayBuffer``. For security reasons browsers only provide
``SharedArrayBuffer`` in secure environments with cross-origin isolation. The
webserver must send cross-origin headers and correct MIME types for the
JavaScript and WebAssembly files. Otherwise the terminal will fail to load with
an error message like ``ReferenceError: SharedArrayBuffer is not defined``. See
more information here:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements

Note that ``SharedArrayBuffer`` is _not required_ to use Python itself, only the
web example. If cross-origin isolation is not appropriate for your use case you
may make your own application embedding `python.mjs` which does not use
``SharedArrayBuffer`` and serve it without the cross-origin isolation headers.

### Embedding Python in a custom JavaScript application

You can look at `python.worker.mjs` and `node_entry.mjs` for inspiration. At a
minimum you must import ``createEmscriptenModule`` and you need to call
``createEmscriptenModule`` with an appropriate settings object. This settings
object will need a prerun hook that installs the Python standard library into
the Emscripten file system.

#### NodeJs

In Node, you can use the NodeFS to mount the standard library in your native
file system into the Emscripten file system:
```js
import createEmscriptenModule from "./python.mjs";

await createEmscriptenModule({
preRun(Module) {
Module.FS.mount(
Module.FS.filesystems.NODEFS,
{ root: "/path/to/python/stdlib" },
"/lib/",
);
},
});
```

#### Browser

In the browser, the simplest approach is to put the standard library in a zip
file it and install it. With Python 3.14 this could look like:
```js
import createEmscriptenModule from "./python.mjs";

await createEmscriptenModule({
async preRun(Module) {
Module.FS.mkdirTree("/lib/python3.14/lib-dynload/");
Module.addRunDependency("install-stdlib");
const resp = await fetch("python3.14.zip");
const stdlibBuffer = await resp.arrayBuffer();
Module.FS.writeFile(`/lib/python314.zip`, new Uint8Array(stdlibBuffer), {
canOwn: true,
});
Module.removeRunDependency("install-stdlib");
},
});
```

### Limitations and issues

#### Network stack
Expand Down Expand Up @@ -151,38 +233,6 @@ python Tools/wasm/emscripten build --with-py-debug
- Test modules are disabled by default. Use ``--enable-test-modules`` build
test modules like ``_testcapi``.

### wasm32-emscripten in node

Node builds use ``NODERAWFS``.

- Node RawFS allows direct access to the host file system without need to
perform ``FS.mount()`` call.

### Hosting Python WASM builds

The simple REPL terminal uses SharedArrayBuffer. For security reasons
browsers only provide the feature in secure environments with cross-origin
isolation. The webserver must send cross-origin headers and correct MIME types
for the JavaScript and WASM files. Otherwise the terminal will fail to load
with an error message like ``Browsers disable shared array buffer``.

#### Apache HTTP .htaccess

Place a ``.htaccess`` file in the same directory as ``python.wasm``.

```
# .htaccess
Header set Cross-Origin-Opener-Policy same-origin
Header set Cross-Origin-Embedder-Policy require-corp

AddType application/javascript js
AddType application/wasm wasm

<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html application/javascript application/wasm
</IfModule>
```

## WASI (wasm32-wasi)

See [the devguide on how to build and run for WASI](https://devguide.python.org/getting-started/setup-building/#wasi).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

async initialiseWorker() {
if (!this.worker) {
this.worker = new Worker(this.workerURL)
this.worker = new Worker(this.workerURL, {type: "module"})
this.worker.addEventListener('message', this.handleMessageFromWorker)
}
}
Expand Down Expand Up @@ -347,7 +347,7 @@
programRunning(false)
}

const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback, finishedCallback)
const pythonWorkerManager = new WorkerManager('./python.worker.mjs', stdio, readyCallback, finishedCallback)
}
</script>
</head>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import createEmscriptenModule from "./python.mjs";

class StdinBuffer {
constructor() {
this.sab = new SharedArrayBuffer(128 * Int32Array.BYTES_PER_ELEMENT)
Expand Down Expand Up @@ -59,29 +61,44 @@ const stderr = (charCode) => {

const stdinBuffer = new StdinBuffer()

var Module = {
const emscriptenSettings = {
noInitialRun: true,
stdin: stdinBuffer.stdin,
stdout: stdout,
stderr: stderr,
onRuntimeInitialized: () => {
postMessage({type: 'ready', stdinBuffer: stdinBuffer.sab})
},
async preRun(Module) {
const versionHex = Module.HEAPU32[Module._Py_Version/4].toString(16);
const versionTuple = versionHex.padStart(8, "0").match(/.{1,2}/g).map((x) => parseInt(x, 16));
const [major, minor, ..._] = versionTuple;
// Prevent complaints about not finding exec-prefix by making a lib-dynload directory
Module.FS.mkdirTree(`/lib/python${major}.${minor}/lib-dynload/`);
Module.addRunDependency("install-stdlib");
const resp = await fetch(`python${major}.${minor}.zip`);
const stdlibBuffer = await resp.arrayBuffer();
Module.FS.writeFile(`/lib/python${major}${minor}.zip`, new Uint8Array(stdlibBuffer), { canOwn: true });
Module.removeRunDependency("install-stdlib");
}
}

onmessage = (event) => {
const modulePromise = createEmscriptenModule(emscriptenSettings);


onmessage = async (event) => {
if (event.data.type === 'run') {
const Module = await modulePromise;
if (event.data.files) {
for (const [filename, contents] of Object.entries(event.data.files)) {
Module.FS.writeFile(filename, contents)
}
}
const ret = callMain(event.data.args)
const ret = Module.callMain(event.data.args);
postMessage({
type: 'finished',
returnCode: ret
})
}
}

importScripts('python.js')
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@


class MyHTTPRequestHandler(server.SimpleHTTPRequestHandler):
extensions_map = server.SimpleHTTPRequestHandler.extensions_map.copy()
extensions_map.update(
{
".wasm": "application/wasm",
}
)

def end_headers(self) -> None:
self.send_my_headers()
super().end_headers()
Expand All @@ -42,5 +35,6 @@ def main() -> None:
bind=args.bind,
)


if __name__ == "__main__":
main()
Loading
Loading