Skip to content

Commit

Permalink
gh-127111: Emscripten Make web example work again (#127113)
Browse files Browse the repository at this point in the history
Moves the Emscripten web example into a standalone folder, and updates 
Makefile targets to build the web example. Instructions for usage have
also been added.
  • Loading branch information
hoodmane authored Dec 2, 2024
1 parent edefb86 commit bfb0788
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 92 deletions.
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

0 comments on commit bfb0788

Please sign in to comment.