1
/// <reference lib="webworker" />
4
import type { PyodideInterface } from "pyodide";
5
import type { PyProxy } from "pyodide/ffi";
13
} from "../message-types";
18
resolveAppHomeBasedPath
20
import { verifyRequirements } from "./requirements";
21
import { makeAsgiRequest } from "./asgi";
22
import { generateRandomString } from "./random";
23
import scriptRunnerPySource from "./py/script_runner.py?raw";
24
import unloadModulesPySource from "./py/unload_modules.py?raw";
26
importScripts("https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js");
28
type MessageTransceiver = DedicatedWorkerGlobalScope | MessagePort;
30
let pyodide: PyodideInterface;
33
let call_asgi_app_from_js: (
36
receive: () => Promise<unknown>,
37
send: (event: any) => Promise<void>
50
let unload_local_modules: (target_dir_path?: string) => void;
52
async function initializeEnvironment(
53
options: InMessageInitEnv["data"],
54
updateProgress: (log: string) => void
56
console.debug("Loading Pyodide.");
57
updateProgress("Loading Pyodide");
58
pyodide = await loadPyodide({
59
stdout: console.debug,
62
console.debug("Pyodide is loaded.");
64
console.debug("Loading micropip");
65
updateProgress("Loading micropip");
66
await pyodide.loadPackage("micropip");
67
micropip = pyodide.pyimport("micropip");
68
console.debug("micropip is loaded.");
70
const gradioWheelUrls = [
71
options.gradioWheelUrl,
72
options.gradioClientWheelUrl
74
console.debug("Loading Gradio wheels.", gradioWheelUrls);
75
updateProgress("Loading Gradio wheels");
76
await micropip.add_mock_package("ffmpy", "0.3.0");
77
await micropip.add_mock_package("aiohttp", "3.8.4");
78
await pyodide.loadPackage(["ssl", "setuptools"]);
79
await micropip.install(["typing-extensions>=4.8.0"]); // Typing extensions needs to be installed first otherwise the versions from the pyodide lockfile is used which is incompatible with the latest fastapi.
80
await micropip.install(["markdown-it-py[linkify]~=2.2.0"]); // On 3rd June 2023, markdown-it-py 3.0.0 has been released. The `gradio` package depends on its `>=2.0.0` version so its 3.x will be resolved. However, it conflicts with `mdit-py-plugins`'s dependency `markdown-it-py >=1.0.0,<3.0.0` and micropip currently can't resolve it. So we explicitly install the compatible version of the library here.
81
await micropip.install(["anyio==3.*"]); // `fastapi` depends on `anyio>=3.4.0,<5` so its 4.* can be installed, but it conflicts with the anyio version `httpx` depends on, `==3.*`. Seems like micropip can't resolve it for now, so we explicitly install the compatible version of the library here.
82
await micropip.add_mock_package("pydantic", "2.4.2"); // PydanticV2 is not supported on Pyodide yet. Mock it here for installing the `gradio` package to pass the version check. Then, install PydanticV1 below.
83
await micropip.add_mock_package("ruff", "0.2.2"); // `ruff` was added to the requirements of `gradio` for the custom components (https://github.com/gradio-app/gradio/pull/7030), but it's not working on PYodide yet. Also Lite doesn't need it, so mock it here for installing the `gradio` package to pass the version check.
84
await micropip.install.callKwargs(gradioWheelUrls, {
87
await micropip.remove_mock_package("pydantic");
88
await micropip.install(["pydantic==1.*"]); // Pydantic is necessary for `gradio` to run, so install v1 here as a fallback. Some tricks has been introduced in `gradio/data_classes.py` to make it work with v1.
89
console.debug("Gradio wheels are loaded.");
91
console.debug("Mocking os module methods.");
92
updateProgress("Mock os module methods");
93
// `os.link` is used in `aiofiles` (https://github.com/Tinche/aiofiles/blob/v23.1.0/src/aiofiles/os.py#L31),
94
// which is imported from `gradio.ranged_response` (https://github.com/gradio-app/gradio/blob/v3.32.0/gradio/ranged_response.py#L12).
95
// However, it's not available on Wasm.
96
await pyodide.runPythonAsync(`
99
os.link = lambda src, dst: None
101
console.debug("os module methods are mocked.");
103
console.debug("Importing gradio package.");
104
updateProgress("Importing gradio package");
105
// Importing the gradio package takes a long time, so we do it separately.
106
// This is necessary for accurate performance profiling.
107
await pyodide.runPythonAsync(`import gradio`);
108
console.debug("gradio package is imported.");
110
console.debug("Defining a ASGI wrapper function.");
111
updateProgress("Defining a ASGI wrapper function");
112
// TODO: Unlike Streamlit, user's code is executed in the global scope,
113
// so we should not define this function in the global scope.
114
await pyodide.runPythonAsync(`
115
# Based on Shiny's App.call_pyodide().
116
# https://github.com/rstudio/py-shiny/blob/v0.3.3/shiny/_app.py#L224-L258
117
async def _call_asgi_app_from_js(app_id, scope, receive, send):
118
# TODO: Pretty sure there are objects that need to be destroy()'d here?
119
scope = scope.to_py()
121
# ASGI requires some values to be byte strings, not character strings. Those are
122
# not that easy to create in JavaScript, so we let the JS side pass us strings
123
# and we convert them to bytes here.
124
if "headers" in scope:
125
# JS doesn't have \`bytes\` so we pass as strings and convert here
127
[value.encode("latin-1") for value in header]
128
for header in scope["headers"]
130
if "query_string" in scope and scope["query_string"]:
131
scope["query_string"] = scope["query_string"].encode("latin-1")
132
if "raw_path" in scope and scope["raw_path"]:
133
scope["raw_path"] = scope["raw_path"].encode("latin-1")
136
event = await receive()
139
async def snd(event):
142
app = gradio.wasm_utils.get_registered_app(app_id)
144
raise RuntimeError("Gradio app has not been launched.")
146
await app(scope, rcv, snd)
148
call_asgi_app_from_js = pyodide.globals.get("_call_asgi_app_from_js");
149
console.debug("The ASGI wrapper function is defined.");
151
console.debug("Mocking async libraries.");
152
updateProgress("Mocking async libraries");
153
// FastAPI uses `anyio.to_thread.run_sync` internally which, however, doesn't work in Wasm environments where the `threading` module is not supported.
154
// So we mock `anyio.to_thread.run_sync` here not to use threads.
155
await pyodide.runPythonAsync(`
156
async def mocked_anyio_to_thread_run_sync(func, *args, cancellable=False, limiter=None):
159
import anyio.to_thread
160
anyio.to_thread.run_sync = mocked_anyio_to_thread_run_sync
162
console.debug("Async libraries are mocked.");
164
console.debug("Setting matplotlib backend.");
165
updateProgress("Setting matplotlib backend");
166
// Ref: https://github.com/streamlit/streamlit/blob/1.22.0/lib/streamlit/web/bootstrap.py#L111
167
// This backend setting is required to use matplotlib in Wasm environment.
168
await pyodide.runPythonAsync(`
172
console.debug("matplotlib backend is set.");
174
console.debug("Setting up Python utility functions.");
175
updateProgress("Setting up Python utility functions");
176
await pyodide.runPythonAsync(scriptRunnerPySource);
177
run_code = pyodide.globals.get("_run_code");
178
run_script = pyodide.globals.get("_run_script");
179
await pyodide.runPythonAsync(unloadModulesPySource);
180
unload_local_modules = pyodide.globals.get("unload_local_modules");
181
console.debug("Python utility functions are set up.");
183
updateProgress("Initialization completed");
186
async function initializeApp(
188
options: InMessageInitApp["data"],
189
updateProgress: (log: string) => void
191
const appHomeDir = getAppHomeDir(appId);
192
console.debug("Creating a home directory for the app.", {
196
pyodide.FS.mkdir(appHomeDir);
198
console.debug("Mounting files.", options.files);
199
updateProgress("Mounting files");
201
Object.keys(options.files).map(async (path) => {
202
const file = options.files[path];
204
let data: string | ArrayBufferView;
206
console.debug(`Fetch a file from ${file.url}`);
207
data = await fetch(file.url)
208
.then((res) => res.arrayBuffer())
209
.then((buffer) => new Uint8Array(buffer));
213
const { opts } = options.files[path];
215
const appifiedPath = resolveAppHomeBasedPath(appId, path);
216
console.debug(`Write a file "${appifiedPath}"`);
217
writeFileWithParents(pyodide, appifiedPath, data, opts);
220
console.debug("Files are mounted.");
222
console.debug("Installing packages.", options.requirements);
223
updateProgress("Installing packages");
224
await micropip.install.callKwargs(options.requirements, { keep_going: true });
225
console.debug("Packages are installed.");
228
const ctx = self as DedicatedWorkerGlobalScope | SharedWorkerGlobalScope;
231
* Set up the onmessage event listener.
233
if ("postMessage" in ctx) {
235
setupMessageHandler(ctx);
238
ctx.onconnect = (event: MessageEvent): void => {
239
const port = event.ports[0];
241
setupMessageHandler(port);
247
// Environment initialization is global and should be done only once, so its promise is managed in a global scope.
248
let envReadyPromise: Promise<void> | undefined = undefined;
250
function setupMessageHandler(receiver: MessageTransceiver): void {
251
// A concept of "app" is introduced to support multiple apps in a single worker.
252
// Each app has its own home directory (`getAppHomeDir(appId)`) in a shared single Pyodide filesystem.
253
// The home directory is used as the current working directory for the app.
254
// Each frontend app has a connection to the worker which is the `receiver` object passed above
255
// and it is associated with one app.
256
// One app also has one Gradio server app which is managed by the `gradio.wasm_utils` module.`
257
// This multi-app mechanism was introduced for a SharedWorker, but the same mechanism is used for a DedicatedWorker as well.
258
const appId = generateRandomString(8);
260
console.debug("Set up a new app.", { appId });
262
const updateProgress = (log: string): void => {
263
const message: OutMessage = {
264
type: "progress-update",
269
receiver.postMessage(message);
272
// App initialization is per app or receiver, so its promise is managed in this scope.
273
let appReadyPromise: Promise<void> | undefined = undefined;
275
receiver.onmessage = async function (
276
event: MessageEvent<InMessage>
278
const msg = event.data;
279
console.debug("worker.onmessage", msg);
281
const messagePort = event.ports[0];
284
if (msg.type === "init-env") {
285
if (envReadyPromise == null) {
286
envReadyPromise = initializeEnvironment(msg.data, updateProgress);
289
"Pyodide environment initialization is ongoing in another session"
295
const replyMessage: ReplyMessageSuccess = {
296
type: "reply:success",
299
messagePort.postMessage(replyMessage);
302
const replyMessage: ReplyMessageError = {
306
messagePort.postMessage(replyMessage);
311
if (envReadyPromise == null) {
312
throw new Error("Pyodide Initialization is not started.");
314
await envReadyPromise;
316
if (msg.type === "init-app") {
317
appReadyPromise = initializeApp(appId, msg.data, updateProgress);
319
const replyMessage: ReplyMessageSuccess = {
320
type: "reply:success",
323
messagePort.postMessage(replyMessage);
327
if (appReadyPromise == null) {
328
throw new Error("App initialization is not started.");
330
await appReadyPromise;
334
const replyMessage: ReplyMessageSuccess = {
335
type: "reply:success",
338
messagePort.postMessage(replyMessage);
341
case "run-python-code": {
342
unload_local_modules();
344
await run_code(appId, getAppHomeDir(appId), msg.data.code);
346
const replyMessage: ReplyMessageSuccess = {
347
type: "reply:success",
348
data: null // We don't send back the execution result because it's not needed for our purpose, and sometimes the result is of type `pyodide.ffi.PyProxy` which cannot be cloned across threads and causes an error.
350
messagePort.postMessage(replyMessage);
353
case "run-python-file": {
354
unload_local_modules();
356
await run_script(appId, getAppHomeDir(appId), msg.data.path);
358
const replyMessage: ReplyMessageSuccess = {
359
type: "reply:success",
362
messagePort.postMessage(replyMessage);
365
case "asgi-request": {
366
console.debug("ASGI request", msg.data);
368
call_asgi_app_from_js.bind(null, appId),
371
); // This promise is not awaited because it won't resolves until the HTTP connection is closed.
375
const { path, data: fileData, opts } = msg.data;
377
const appifiedPath = resolveAppHomeBasedPath(appId, path);
379
console.debug(`Write a file "${appifiedPath}"`);
380
writeFileWithParents(pyodide, appifiedPath, fileData, opts);
382
const replyMessage: ReplyMessageSuccess = {
383
type: "reply:success",
386
messagePort.postMessage(replyMessage);
389
case "file:rename": {
390
const { oldPath, newPath } = msg.data;
392
const appifiedOldPath = resolveAppHomeBasedPath(appId, oldPath);
393
const appifiedNewPath = resolveAppHomeBasedPath(appId, newPath);
394
console.debug(`Rename "${appifiedOldPath}" to ${appifiedNewPath}`);
395
renameWithParents(pyodide, appifiedOldPath, appifiedNewPath);
397
const replyMessage: ReplyMessageSuccess = {
398
type: "reply:success",
401
messagePort.postMessage(replyMessage);
404
case "file:unlink": {
405
const { path } = msg.data;
407
const appifiedPath = resolveAppHomeBasedPath(appId, path);
409
console.debug(`Remove "${appifiedPath}`);
410
pyodide.FS.unlink(appifiedPath);
412
const replyMessage: ReplyMessageSuccess = {
413
type: "reply:success",
416
messagePort.postMessage(replyMessage);
420
const { requirements } = msg.data;
422
const micropip = pyodide.pyimport("micropip");
424
console.debug("Install the requirements:", requirements);
425
verifyRequirements(requirements); // Blocks the not allowed wheel URL schemes.
426
await micropip.install
427
.callKwargs(requirements, { keep_going: true })
429
if (requirements.includes("matplotlib")) {
430
// Ref: https://github.com/streamlit/streamlit/blob/1.22.0/lib/streamlit/web/bootstrap.py#L111
431
// This backend setting is required to use matplotlib in Wasm environment.
432
return pyodide.runPythonAsync(`
434
matplotlib.use("agg")
439
console.debug("Successfully installed");
441
const replyMessage: ReplyMessageSuccess = {
442
type: "reply:success",
445
messagePort.postMessage(replyMessage);
451
console.error(error);
453
if (!(error instanceof Error)) {
457
// The `error` object may contain non-serializable properties such as function (for example Pyodide.FS.ErrnoError which has a `.setErrno` function),
458
// so it must be converted to a plain object before sending it to the main thread.
459
// Otherwise, the following error will be thrown:
460
// `Uncaught (in promise) DOMException: Failed to execute 'postMessage' on 'MessagePort': #<Object> could not be cloned.`
461
// Also, the JSON.stringify() and JSON.parse() approach like https://stackoverflow.com/a/42376465/13103190
462
// does not work for Error objects because the Error object is not enumerable.
463
// So we use the following approach to clone the Error object.
464
const cloneableError = new Error(error.message);
465
cloneableError.name = error.name;
466
cloneableError.stack = error.stack;
468
const replyMessage: ReplyMessageError = {
470
error: cloneableError
472
messagePort.postMessage(replyMessage);