File
Blob: src/pyodide/python-entrypoint-helper.ts
| 1 | // Copyright (c) 2026 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | /* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ |
| 6 | // This file is a BUILTIN module that provides the actual implementation for the |
| 7 | // python-entrypoint.js USER module. |
| 8 | |
| 9 | import { patch_env_helper } from 'pyodide-internal:envHelpers'; |
| 10 | import { enterJaegerSpan } from 'pyodide-internal:jaeger'; |
| 11 | import { default as Limiter } from 'pyodide-internal:limiter'; |
| 12 | import { |
| 13 | COMPATIBILITY_FLAGS, |
| 14 | IS_WORKERD, |
| 15 | LEGACY_GLOBAL_HANDLERS, |
| 16 | EXTERNAL_SDK, |
| 17 | LOCKFILE, |
| 18 | MAIN_MODULE_NAME, |
| 19 | SHOULD_SNAPSHOT_TO_DISK, |
| 20 | TRANSITIVE_REQUIREMENTS, |
| 21 | WORKFLOWS_ENABLED, |
| 22 | } from 'pyodide-internal:metadata'; |
| 23 | import { |
| 24 | beforeRequest, |
| 25 | clearSignals, |
| 26 | loadPyodide, |
| 27 | } from 'pyodide-internal:python'; |
| 28 | import { patchLoadPackage } from 'pyodide-internal:setupPackages'; |
| 29 | import { |
| 30 | fillSnapshotJsModules, |
| 31 | LOADED_SNAPSHOT_TYPE, |
| 32 | maybeCollectDedicatedSnapshot, |
| 33 | } from 'pyodide-internal:snapshot'; |
| 34 | import { |
| 35 | PythonUserError, |
| 36 | PythonWorkersInternalError, |
| 37 | loadPythonMod, |
| 38 | reportError, |
| 39 | } from 'pyodide-internal:util'; |
| 40 | import { PyodideVersion } from 'pyodide-internal:const'; |
| 41 | import { default as introspectionSource } from 'pyodide-internal:introspection.py'; |
| 42 | export { createImportProxy } from 'pyodide-internal:serializeJsModule'; |
| 43 | |
| 44 | type PyFuture<T> = Promise<T> & { copy(): PyFuture<T>; destroy(): void }; |
| 45 | |
| 46 | const waitUntilPatched = new WeakSet(); |
| 47 | |
| 48 | function patchWaitUntil(ctx: { |
| 49 | waitUntil: (p: Promise<void> | PyFuture<void>) => void; |
| 50 | }): void { |
| 51 | let tag; |
| 52 | try { |
| 53 | tag = Object.prototype.toString.call(ctx); |
| 54 | } catch (_e) {} |
| 55 | if (tag !== '[object ExecutionContext]') { |
| 56 | return; |
| 57 | } |
| 58 | if (waitUntilPatched.has(ctx)) { |
| 59 | return; |
| 60 | } |
| 61 | const origWaitUntil: (p: Promise<void>) => void = ctx.waitUntil.bind(ctx); |
| 62 | function waitUntil(p: Promise<void> | PyFuture<void>): void { |
| 63 | origWaitUntil( |
| 64 | (async function (): Promise<void> { |
| 65 | if ('copy' in p) { |
| 66 | p = p.copy(); |
| 67 | } |
| 68 | await p; |
| 69 | if ('destroy' in p) { |
| 70 | p.destroy(); |
| 71 | } |
| 72 | })() |
| 73 | ); |
| 74 | } |
| 75 | ctx.waitUntil = waitUntil; |
| 76 | waitUntilPatched.add(ctx); |
| 77 | } |
| 78 | |
| 79 | export type PyodideEntrypointHelper = { |
| 80 | doAnImport: (mod: string) => Promise<any>; |
| 81 | cloudflareWorkersModule: { env: any }; |
| 82 | cloudflareSocketsModule: any; |
| 83 | workerEntrypoint: any; |
| 84 | patchWaitUntil: typeof patchWaitUntil; |
| 85 | patch_env_helper: (patch: unknown) => Generator<void>; |
| 86 | TEST_ONLY_THROW_PYTHON_WORKERS_INTERNAL_ERROR: (message: string) => never; |
| 87 | }; |
| 88 | |
| 89 | // Function to import JavaScript modules from Python |
| 90 | let _pyodide_entrypoint_helper: PyodideEntrypointHelper | null = null; |
| 91 | |
| 92 | function get_pyodide_entrypoint_helper(): PyodideEntrypointHelper { |
| 93 | if (!_pyodide_entrypoint_helper) { |
| 94 | throw new PythonWorkersInternalError( |
| 95 | 'pyodide_entrypoint_helper is not initialized' |
| 96 | ); |
| 97 | } |
| 98 | return _pyodide_entrypoint_helper; |
| 99 | } |
| 100 | |
| 101 | export async function setDoAnImport( |
| 102 | doAnImport: (mod: string) => Promise<any>, |
| 103 | workerEntrypoint: any |
| 104 | ): Promise<void> { |
| 105 | _pyodide_entrypoint_helper = { |
| 106 | doAnImport, |
| 107 | cloudflareWorkersModule: await doAnImport('cloudflare:workers'), |
| 108 | cloudflareSocketsModule: await doAnImport('cloudflare:sockets'), |
| 109 | workerEntrypoint, |
| 110 | patchWaitUntil, |
| 111 | patch_env_helper, |
| 112 | TEST_ONLY_THROW_PYTHON_WORKERS_INTERNAL_ERROR(message: string): never { |
| 113 | if (!COMPATIBILITY_FLAGS.experimental) { |
| 114 | throw new Error( |
| 115 | 'TEST_ONLY_THROW_PYTHON_WORKERS_INTERNAL_ERROR requires the experimental compatibility flag' |
| 116 | ); |
| 117 | } |
| 118 | throw new PythonWorkersInternalError(message); |
| 119 | }, |
| 120 | }; |
| 121 | await fillSnapshotJsModules(doAnImport); |
| 122 | } |
| 123 | |
| 124 | function handleSrcImport(pyodide: Pyodide, e: any): never { |
| 125 | // Users may be expecting to import local modules via the `src` directory, which for a default |
| 126 | // project structure will fail. This code will add some extra info to the error message to help |
| 127 | // them fix it. |
| 128 | if (e.name === 'PythonError' && e.type === 'ModuleNotFoundError') { |
| 129 | pyodide.runPython(` |
| 130 | try: |
| 131 | import sys |
| 132 | exc = sys.last_value |
| 133 | if exc.name == "src": |
| 134 | exc.add_note( |
| 135 | "If your main module is inside the 'src' directory then your import " + |
| 136 | "statement shouldn't include a 'src.' prefix") |
| 137 | raise exc |
| 138 | finally: |
| 139 | del exc |
| 140 | `); |
| 141 | } |
| 142 | throw e; |
| 143 | } |
| 144 | |
| 145 | async function pyimportMainModule(pyodide: Pyodide): Promise<PyModule> { |
| 146 | if (!MAIN_MODULE_NAME.endsWith('.py')) { |
| 147 | throw new PythonUserError( |
| 148 | 'Main module needs to end with a .py file extension' |
| 149 | ); |
| 150 | } |
| 151 | const mainModuleName = MAIN_MODULE_NAME.slice(0, -3); |
| 152 | if (pyodide.version === PyodideVersion.V0_26_0a2) { |
| 153 | return pyodide.pyimport(mainModuleName); |
| 154 | } else { |
| 155 | return await pyodide._module.API.pyodide_base.pyimport_impl.callPromising( |
| 156 | mainModuleName |
| 157 | ); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | let pyodidePromise: Promise<Pyodide> | undefined; |
| 162 | async function getPyodide(): Promise<Pyodide> { |
| 163 | return await enterJaegerSpan('get_pyodide', () => { |
| 164 | if (pyodidePromise) { |
| 165 | return pyodidePromise; |
| 166 | } |
| 167 | pyodidePromise = (async function (): Promise<Pyodide> { |
| 168 | const pyodide = await loadPyodide(IS_WORKERD, LOCKFILE, { |
| 169 | pyodide_entrypoint_helper: get_pyodide_entrypoint_helper(), |
| 170 | cloudflare_compat_flags: COMPATIBILITY_FLAGS, |
| 171 | }); |
| 172 | await setupPatches(pyodide); |
| 173 | return pyodide; |
| 174 | })(); |
| 175 | return pyodidePromise; |
| 176 | }); |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Import the data from the data module es6 import called jsModName.py into a module called |
| 181 | * pyModName.py. The site_packages directory is on the path. |
| 182 | */ |
| 183 | async function injectSitePackagesModule( |
| 184 | pyodide: Pyodide, |
| 185 | jsModName: string, |
| 186 | pyModName: string |
| 187 | ): Promise<void> { |
| 188 | const mod = await import(`pyodide-internal:${jsModName}.py`); |
| 189 | pyodide.FS.writeFile( |
| 190 | `${pyodide.FS.sitePackages}/${pyModName}.py`, |
| 191 | new Uint8Array(mod.default), |
| 192 | { canOwn: true } |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Put the patch into site_packages and import it. |
| 198 | * |
| 199 | * TODO: Ideally we should only import the patch lazily when the package that it patches is |
| 200 | * imported. Or just apply the patch directly or upstream a fix. |
| 201 | */ |
| 202 | async function applyPatch(pyodide: Pyodide, patchName: string): Promise<void> { |
| 203 | await injectSitePackagesModule( |
| 204 | pyodide, |
| 205 | `patches/${patchName}`, |
| 206 | patchName + '_patch' |
| 207 | ); |
| 208 | pyodide.pyimport(patchName + '_patch'); |
| 209 | } |
| 210 | |
| 211 | async function injectWorkersApi(pyodide: Pyodide): Promise<void> { |
| 212 | if (EXTERNAL_SDK) { |
| 213 | pyodide.FS.mkdir(`${pyodide.FS.sitePackages}/workers`); |
| 214 | const template = [ |
| 215 | `err = ModuleNotFoundError("No module named '$MODNAME'", name="$MODNAME")`, |
| 216 | `err.add_note("You need to update to workers-py >= 1.90 or to pass disable_python_external_sdk")`, |
| 217 | `raise err`, |
| 218 | ].join('\n'); |
| 219 | pyodide.FS.writeFile( |
| 220 | `${pyodide.FS.sitePackages}/workers/__init__.py`, |
| 221 | template.replaceAll('$MODNAME', 'workers') |
| 222 | ); |
| 223 | pyodide.FS.writeFile( |
| 224 | `${pyodide.FS.sitePackages}/asgi.py`, |
| 225 | template.replaceAll('$MODNAME', 'asgi') |
| 226 | ); |
| 227 | return; |
| 228 | } |
| 229 | |
| 230 | const sitePackages = pyodide.FS.sitePackages; |
| 231 | if (pyodide.version === PyodideVersion.V0_26_0a2) { |
| 232 | // Inject at cloudflare.workers for backwards compatibility |
| 233 | pyodide.FS.mkdirTree(`${sitePackages}/cloudflare/workers`); |
| 234 | await injectSitePackagesModule( |
| 235 | pyodide, |
| 236 | 'workers-api/src/workers/__init__', |
| 237 | 'cloudflare/workers/__init__' |
| 238 | ); |
| 239 | await injectSitePackagesModule( |
| 240 | pyodide, |
| 241 | 'workers-api/src/workers/_workers', |
| 242 | 'cloudflare/workers/_workers' |
| 243 | ); |
| 244 | } |
| 245 | // The SDK was moved from `cloudflare.workers` to just `workers`. |
| 246 | // Create workers package structure with workflows submodule |
| 247 | pyodide.FS.mkdir(`${sitePackages}/workers`); |
| 248 | await injectSitePackagesModule( |
| 249 | pyodide, |
| 250 | 'workers-api/src/workers/__init__', |
| 251 | 'workers/__init__' |
| 252 | ); |
| 253 | await injectSitePackagesModule( |
| 254 | pyodide, |
| 255 | 'workers-api/src/workers/_workers', |
| 256 | 'workers/_workers' |
| 257 | ); |
| 258 | await injectSitePackagesModule( |
| 259 | pyodide, |
| 260 | 'workers-api/src/workers/workflows', |
| 261 | 'workers/workflows' |
| 262 | ); |
| 263 | await injectSitePackagesModule(pyodide, 'workers-api/src/asgi', 'asgi'); |
| 264 | } |
| 265 | |
| 266 | async function setupPatches(pyodide: Pyodide): Promise<void> { |
| 267 | await enterJaegerSpan('setup_patches', async () => { |
| 268 | patchLoadPackage(pyodide); |
| 269 | |
| 270 | // install any extra packages into the site-packages directory |
| 271 | // Expose the doAnImport function and global modules to Python globals |
| 272 | pyodide.registerJsModule( |
| 273 | '_pyodide_entrypoint_helper', |
| 274 | get_pyodide_entrypoint_helper() |
| 275 | ); |
| 276 | |
| 277 | pyodide.registerJsModule('_cloudflare_compat_flags', COMPATIBILITY_FLAGS); |
| 278 | |
| 279 | // Inject modules that enable JS features to be used idiomatically from Python. |
| 280 | await injectWorkersApi(pyodide); |
| 281 | |
| 282 | // Install patches as needed |
| 283 | if (TRANSITIVE_REQUIREMENTS.has('aiohttp')) { |
| 284 | await applyPatch(pyodide, 'aiohttp'); |
| 285 | } |
| 286 | // Other than the oldest version of httpx, we apply the patch at the build step. |
| 287 | if ( |
| 288 | pyodide._module.API.version === PyodideVersion.V0_26_0a2 && |
| 289 | TRANSITIVE_REQUIREMENTS.has('httpx') |
| 290 | ) { |
| 291 | await applyPatch(pyodide, 'httpx'); |
| 292 | } |
| 293 | }); |
| 294 | } |
| 295 | |
| 296 | let mainModulePromise: Promise<PyModule> | undefined; |
| 297 | function getMainModule(): Promise<PyModule> { |
| 298 | return enterJaegerSpan('get_main_module', async () => { |
| 299 | if (mainModulePromise) { |
| 300 | return mainModulePromise; |
| 301 | } |
| 302 | mainModulePromise = (async function (): Promise<PyModule> { |
| 303 | const pyodide = await getPyodide(); |
| 304 | Limiter.beginStartup(); |
| 305 | try { |
| 306 | return await enterJaegerSpan('pyimport_main_module', () => |
| 307 | pyimportMainModule(pyodide) |
| 308 | ); |
| 309 | } catch (e: any) { |
| 310 | handleSrcImport(pyodide, e); |
| 311 | } finally { |
| 312 | Limiter.finishStartup(LOADED_SNAPSHOT_TYPE); |
| 313 | } |
| 314 | })(); |
| 315 | return mainModulePromise; |
| 316 | }); |
| 317 | } |
| 318 | |
| 319 | async function preparePython(): Promise<PyModule> { |
| 320 | try { |
| 321 | const pyodide = await getPyodide(); |
| 322 | const mainModule = await getMainModule(); |
| 323 | beforeRequest(pyodide._module); |
| 324 | return mainModule; |
| 325 | } catch (e) { |
| 326 | // In edgeworker test suite, without this we get the file name and line number of the exception |
| 327 | // but no traceback. This gives us a full traceback. |
| 328 | reportError(e as Error); |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | async function doPyCallHelper( |
| 333 | relaxed: boolean, |
| 334 | pyfunc: PyCallable, |
| 335 | args: any[] |
| 336 | ): Promise<any> { |
| 337 | const pyodide = await getPyodide(); |
| 338 | clearSignals(pyodide._module); |
| 339 | try { |
| 340 | if (pyfunc.callWithOptions) { |
| 341 | return await pyfunc.callWithOptions( |
| 342 | { relaxed, promising: true }, |
| 343 | ...args |
| 344 | ); |
| 345 | } |
| 346 | if (relaxed) { |
| 347 | return await pyfunc.callRelaxed(...args); |
| 348 | } |
| 349 | return await pyfunc(...args); |
| 350 | } catch (e: any) { |
| 351 | const pyodide = await getPyodide(); |
| 352 | handleSrcImport(pyodide, e); |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | function doPyCall(pyfunc: PyCallable, args: any[]): any { |
| 357 | return doPyCallHelper(false, pyfunc, args); |
| 358 | } |
| 359 | |
| 360 | function doRelaxedPyCall(pyfunc: PyCallable, args: any[]): any { |
| 361 | return doPyCallHelper(true, pyfunc, args); |
| 362 | } |
| 363 | |
| 364 | function makeHandler(pyHandlerName: string): Handler { |
| 365 | if ( |
| 366 | pyHandlerName === 'test' && |
| 367 | SHOULD_SNAPSHOT_TO_DISK && |
| 368 | LEGACY_GLOBAL_HANDLERS |
| 369 | ) { |
| 370 | return async function () { |
| 371 | await getPyodide(); |
| 372 | console.log('Stored snapshot to disk; quitting without running test'); |
| 373 | }; |
| 374 | } |
| 375 | return async function (...args: any[]) { |
| 376 | const mainModule = await enterJaegerSpan( |
| 377 | 'prep_python', |
| 378 | async () => await preparePython() |
| 379 | ); |
| 380 | const handler = mainModule[pyHandlerName]; |
| 381 | if (!handler) { |
| 382 | throw new PythonUserError( |
| 383 | `Python entrypoint "${MAIN_MODULE_NAME}" does not export a handler named "${pyHandlerName}"` |
| 384 | ); |
| 385 | } |
| 386 | const result = await enterJaegerSpan('python_code', () => { |
| 387 | return doRelaxedPyCall(handler, args); |
| 388 | }); |
| 389 | |
| 390 | // Support returning a pyodide.ffi.FetchResponse. |
| 391 | return result?.js_response ?? result; |
| 392 | }; |
| 393 | } |
| 394 | |
| 395 | async function initPyInstance( |
| 396 | className: string, |
| 397 | args: any[] |
| 398 | ): Promise<PyModule> { |
| 399 | const mainModule = await preparePython(); |
| 400 | const pyClassConstructor = mainModule[className]; |
| 401 | if (typeof pyClassConstructor !== 'function') { |
| 402 | throw new TypeError( |
| 403 | `There is no '${className}' class defined in the Python Worker's main module` |
| 404 | ); |
| 405 | } |
| 406 | const res = await doPyCall(pyClassConstructor, args); |
| 407 | return res as PyModule; |
| 408 | } |
| 409 | |
| 410 | // https://developers.cloudflare.com/workers/runtime-apis/rpc/reserved-methods/ |
| 411 | const SPECIAL_HANDLER_NAMES = ['fetch', 'connect']; |
| 412 | const SPECIAL_DO_HANDLER_NAMES = [ |
| 413 | 'alarm', |
| 414 | 'webSocketMessage', |
| 415 | 'webSocketClose', |
| 416 | 'webSocketError', |
| 417 | ]; |
| 418 | |
| 419 | function makeEntrypointProxyHandler( |
| 420 | pyInstancePromise: Promise<PyModule>, |
| 421 | className: string |
| 422 | ): ProxyHandler<any> { |
| 423 | return { |
| 424 | get(target, prop, receiver): any { |
| 425 | if (typeof prop !== 'string') { |
| 426 | return Reflect.get(target, prop, receiver); |
| 427 | } |
| 428 | const isDurableObject = className === 'DurableObject'; |
| 429 | const isWorkflow = className === 'WorkflowEntrypoint'; |
| 430 | |
| 431 | // Proxy calls to `fetch` to methods named `on_fetch` (and the same for other handlers.) |
| 432 | const isKnownHandler = SPECIAL_HANDLER_NAMES.includes(prop); |
| 433 | const isKnownDoHandler = |
| 434 | isDurableObject && SPECIAL_DO_HANDLER_NAMES.includes(prop); |
| 435 | const isFetch = prop === 'fetch'; |
| 436 | const isWorkflowHandler = isWorkflow && prop === 'run'; |
| 437 | if ((isKnownHandler || isKnownDoHandler) && LEGACY_GLOBAL_HANDLERS) { |
| 438 | prop = 'on_' + prop; |
| 439 | } |
| 440 | |
| 441 | if ( |
| 442 | !LEGACY_GLOBAL_HANDLERS && |
| 443 | prop === 'test' && |
| 444 | SHOULD_SNAPSHOT_TO_DISK |
| 445 | ) { |
| 446 | return async function () { |
| 447 | await getPyodide(); |
| 448 | console.log('Stored snapshot to disk; quitting without running test'); |
| 449 | }; |
| 450 | } |
| 451 | |
| 452 | return async function (...args: any[]): Promise<any> { |
| 453 | // Check if the requested method exists and if so, call it. |
| 454 | const pyInstance = await pyInstancePromise; |
| 455 | |
| 456 | if (typeof pyInstance[prop] !== 'function') { |
| 457 | throw new TypeError(`Method ${prop} does not exist`); |
| 458 | } |
| 459 | |
| 460 | if ((isKnownHandler || isKnownDoHandler) && !isFetch) { |
| 461 | return await doPyCallHelper( |
| 462 | true, |
| 463 | pyInstance[prop] as PyCallable, |
| 464 | args |
| 465 | ); |
| 466 | } |
| 467 | |
| 468 | if (WORKFLOWS_ENABLED && isWorkflowHandler) { |
| 469 | // we're hiding this behind a compat flag for now |
| 470 | return await doPyCallHelper( |
| 471 | true, |
| 472 | pyInstance[prop] as PyCallable, |
| 473 | args |
| 474 | ); |
| 475 | } |
| 476 | |
| 477 | const introspectionMod = await getIntrospectionMod(); |
| 478 | |
| 479 | const isRelaxed = isFetch || prop === 'test'; |
| 480 | return await doPyCall(introspectionMod.wrapper_func, [ |
| 481 | isRelaxed, |
| 482 | pyInstance, |
| 483 | prop, |
| 484 | ...args, |
| 485 | ]); |
| 486 | }; |
| 487 | }, |
| 488 | }; |
| 489 | } |
| 490 | |
| 491 | function makeEntrypointClass( |
| 492 | className: string, |
| 493 | classKind: AnyClass, |
| 494 | methods: string[] |
| 495 | ): any { |
| 496 | const result = class EntrypointWrapper extends classKind { |
| 497 | constructor(...args: any[]) { |
| 498 | super(...args); |
| 499 | // Initialise a Python instance of the class. |
| 500 | const pyInstancePromise = initPyInstance(className, args); |
| 501 | // We do not know the methods that are defined on the RPC class, so we need a proxy to |
| 502 | // support any possible method name. |
| 503 | return new Proxy( |
| 504 | this, |
| 505 | makeEntrypointProxyHandler(pyInstancePromise, classKind.name) |
| 506 | ); |
| 507 | } |
| 508 | }; |
| 509 | |
| 510 | // Add dummy functions to the class so that the validator can detect them. These will never get |
| 511 | // accessed because of the proxy at runtime. |
| 512 | for (let method of methods) { |
| 513 | if ( |
| 514 | SUPPORTED_HANDLER_NAMES.includes(method.slice(3)) && |
| 515 | LEGACY_GLOBAL_HANDLERS |
| 516 | ) { |
| 517 | // Remove the "on_" prefix. |
| 518 | method = method.slice(3); |
| 519 | } |
| 520 | result.prototype[method] = function (): void {}; |
| 521 | } |
| 522 | return result; |
| 523 | } |
| 524 | |
| 525 | type IntrospectionMod = { |
| 526 | __dict__: PyDict; |
| 527 | collect_entrypoint_classes: (mod: PyModule) => PythonEntrypointClasses; |
| 528 | wrapper_func: PyCallable; |
| 529 | }; |
| 530 | |
| 531 | let introspectionModPromise: Promise<IntrospectionMod> | null = null; |
| 532 | async function getIntrospectionMod(): Promise<IntrospectionMod> { |
| 533 | if (introspectionModPromise === null) { |
| 534 | introspectionModPromise = (async (): Promise<IntrospectionMod> => { |
| 535 | const pyodide = await getPyodide(); |
| 536 | return loadPythonMod( |
| 537 | pyodide, |
| 538 | 'introspection', |
| 539 | introspectionSource |
| 540 | ) as IntrospectionMod; |
| 541 | })(); |
| 542 | } |
| 543 | |
| 544 | return introspectionModPromise; |
| 545 | } |
| 546 | |
| 547 | const SUPPORTED_HANDLER_NAMES = [ |
| 548 | 'fetch', |
| 549 | 'alarm', |
| 550 | 'scheduled', |
| 551 | 'trace', |
| 552 | 'queue', |
| 553 | 'pubsub', |
| 554 | ]; |
| 555 | |
| 556 | type ExporterClassInfo = { |
| 557 | className: string; |
| 558 | methodNames: string[]; |
| 559 | }; |
| 560 | |
| 561 | type PythonEntrypointClasses = { |
| 562 | durableObjects: ExporterClassInfo[]; |
| 563 | workerEntrypoints: ExporterClassInfo[]; |
| 564 | workflowEntrypoints: ExporterClassInfo[]; |
| 565 | }; |
| 566 | |
| 567 | type PythonInitResult = { |
| 568 | handlers: { [handlerName: string]: Handler }; |
| 569 | pythonEntrypointClasses: PythonEntrypointClasses; |
| 570 | makeEntrypointClass: typeof makeEntrypointClass; |
| 571 | }; |
| 572 | |
| 573 | function handleDefaultClass( |
| 574 | handlers: PythonInitResult['handlers'], |
| 575 | workerEntrypoints: ExporterClassInfo[] |
| 576 | ): void { |
| 577 | const index = workerEntrypoints.findIndex( |
| 578 | (cls) => cls.className === 'Default' |
| 579 | ); |
| 580 | if (index === -1) { |
| 581 | return; |
| 582 | } |
| 583 | const cls = workerEntrypoints[index]!; |
| 584 | |
| 585 | // Disallow defining a `Default` WorkerEntrypoint and other "default" top-level handlers. |
| 586 | if (Object.keys(handlers).length > 0) { |
| 587 | throw new TypeError('Cannot define multiple default entrypoints'); |
| 588 | } |
| 589 | |
| 590 | handlers['default'] = makeEntrypointClass( |
| 591 | 'Default', |
| 592 | get_pyodide_entrypoint_helper().workerEntrypoint, |
| 593 | cls.methodNames |
| 594 | ); |
| 595 | // Remove the default entrypoint from the list of workerEntrypoints to avoid duplication. |
| 596 | workerEntrypoints.splice(index, 1); |
| 597 | } |
| 598 | |
| 599 | export async function initPython(): Promise<PythonInitResult> { |
| 600 | const handlers: { |
| 601 | [handlerName: string]: Handler; |
| 602 | } = {}; |
| 603 | |
| 604 | let pythonEntrypointClasses: PythonEntrypointClasses = { |
| 605 | durableObjects: [], |
| 606 | workerEntrypoints: [], |
| 607 | workflowEntrypoints: [], |
| 608 | }; |
| 609 | |
| 610 | const mainModule = await getMainModule(); |
| 611 | |
| 612 | // In order to get the entrypoint classes exported by the worker, we use a Python module |
| 613 | // to introspect the user's main module. So we are effectively using Python to analyse the |
| 614 | // classes exported by the user worker here. The class names are then exported from here and |
| 615 | // used to create the equivalent JS classes via makeEntrypointClass. |
| 616 | const introspectionMod = await getIntrospectionMod(); |
| 617 | pythonEntrypointClasses = |
| 618 | introspectionMod.collect_entrypoint_classes(mainModule); |
| 619 | handleDefaultClass(handlers, pythonEntrypointClasses.workerEntrypoints); |
| 620 | |
| 621 | if (LEGACY_GLOBAL_HANDLERS) { |
| 622 | // We add all handlers when running in workerd, so that we can handle the case where the |
| 623 | // handler is not defined in our own code and throw a more helpful error. See |
| 624 | // undefined-handler.wd-test. |
| 625 | const addAllHandlers = IS_WORKERD && !handlers['default']; |
| 626 | for (const handlerName of SUPPORTED_HANDLER_NAMES) { |
| 627 | const pyHandlerName = 'on_' + handlerName; |
| 628 | if (addAllHandlers || typeof mainModule[pyHandlerName] === 'function') { |
| 629 | handlers[handlerName] = makeHandler(pyHandlerName); |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | if (typeof mainModule.test === 'function') { |
| 634 | handlers.test = makeHandler('test'); |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | // Collect a dedicated snapshot at the very end. |
| 639 | const pyodide = await getPyodide(); |
| 640 | const customSerializedObjects = { |
| 641 | pyodide_entrypoint_helper: get_pyodide_entrypoint_helper(), |
| 642 | cloudflare_compat_flags: COMPATIBILITY_FLAGS, |
| 643 | }; |
| 644 | maybeCollectDedicatedSnapshot(pyodide._module, customSerializedObjects); |
| 645 | |
| 646 | return { handlers, pythonEntrypointClasses, makeEntrypointClass }; |
| 647 | } |