import { $$ } from 'basic-devtools'; import './styles/pyscript_base.css'; import { loadConfigFromElement } from './pyconfig'; import type { AppConfig, InterpreterConfig } from './pyconfig'; import { InterpreterClient } from './interpreter_client'; import { PluginManager, Plugin, PythonPlugin } from './plugin'; import { make_PyScript, initHandlers, mountElements } from './components/pyscript'; import { getLogger } from './logger'; import { showWarning, createLock } from './utils'; import { calculateFetchPaths } from './plugins/calculateFetchPaths'; import { createCustomElements } from './components/elements'; import { UserError, ErrorCode, _createAlertBanner } from './exceptions'; import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio'; import { PyTerminalPlugin } from './plugins/pyterminal'; import { SplashscreenPlugin } from './plugins/splashscreen'; import { ImportmapPlugin } from './plugins/importmap'; import { StdioDirector as StdioDirector } from './plugins/stdiodirector'; import { RemoteInterpreter } from './remote_interpreter'; import { robustFetch } from './fetch'; import * as Synclink from 'synclink'; const logger = getLogger('pyscript/main'); /** * Monkey patching the error transfer handler to preserve the `$$isUserError` * marker so as to detect `UserError` subclasses in the error handling code. */ const throwHandler = Synclink.transferHandlers.get('throw') as Synclink.TransferHandler< { value: unknown }, { value: { $$isUserError: boolean } } >; const old_error_transfer_handler = throwHandler.serialize.bind(throwHandler) as typeof throwHandler.serialize; function new_error_transfer_handler({ value }: { value: { $$isUserError: boolean } }) { const result = old_error_transfer_handler({ value }); result[0].value.$$isUserError = value.$$isUserError; return result; } throwHandler.serialize = new_error_transfer_handler; /* High-level overview of the lifecycle of a PyScript App: 1. pyscript.js is loaded by the browser. PyScriptApp().main() is called 2. loadConfig(): search for py-config and compute the config for the app 3. (it used to be "show the splashscreen", but now it's a plugin) 4. loadInterpreter(): start downloading the actual interpreter (e.g. pyodide.js) --- wait until (4) has finished --- 5. now the pyodide src is available. Initialize the engine 6. setup the environment, install packages 6.5: call the Plugin.afterSetup() hook 7. connect the py-script web component. This causes the execution of all the user scripts 8. initialize the rest of web components such as py-button, py-repl, etc. */ export let interpreter; // TODO: This is for backwards compatibility, it should be removed // when we finish the deprecation cycle of `runtime` export let runtime; export class PyScriptApp { config: AppConfig; interpreter: InterpreterClient; readyPromise: Promise; PyScript: ReturnType; plugins: PluginManager; _stdioMultiplexer: StdioMultiplexer; tagExecutionLock: () => Promise<() => void>; // this is used to ensure that py-script tags are executed sequentially _numPendingTags: number; scriptTagsPromise: Promise; resolvedScriptTags: () => void; constructor() { // initialize the builtin plugins this.plugins = new PluginManager(); this.plugins.add(new SplashscreenPlugin(), new PyTerminalPlugin(this), new ImportmapPlugin()); this._stdioMultiplexer = new StdioMultiplexer(); this._stdioMultiplexer.addListener(DEFAULT_STDIO); this.plugins.add(new StdioDirector(this._stdioMultiplexer)); this.tagExecutionLock = createLock(); this._numPendingTags = 0; this.scriptTagsPromise = new Promise(res => (this.resolvedScriptTags = res)); } // Error handling logic: if during the execution we encounter an error // which is ultimate responsibility of the user (e.g.: syntax error in the // config, file not found in fetch, etc.), we can throw UserError(). It is // responsibility of main() to catch it and show it to the user in a // proper way (e.g. by using a banner at the top of the page). async main() { try { await this._realMain(); } catch (error) { await this._handleUserErrorMaybe(error); } } incrementPendingTags() { this._numPendingTags += 1; } decrementPendingTags() { if (this._numPendingTags <= 0) { throw new Error('INTERNAL ERROR: assertion _numPendingTags > 0 failed'); } this._numPendingTags -= 1; if (this._numPendingTags === 0) { this.resolvedScriptTags(); } } async _handleUserErrorMaybe(error: any) { const e = error as UserError; if (e && e.$$isUserError) { _createAlertBanner(e.message, 'error', e.messageType); await this.plugins.onUserError(e); } else { throw error; } } // ============ lifecycle ============ // lifecycle (1) async _realMain() { this.loadConfig(); await this.plugins.configure(this.config); this.plugins.beforeLaunch(this.config); await this.loadInterpreter(); interpreter = this.interpreter; // TODO: This is for backwards compatibility, it should be removed // when we finish the deprecation cycle of `runtime` runtime = this.interpreter; } // lifecycle (2) loadConfig() { // find the tag. If not found, we get null which means // "use the default config" // XXX: we should actively complain if there are multiple // and show a big error. PRs welcome :) logger.info('searching for '); const elements = $$('py-config', document); let el: Element | null = null; if (elements.length > 0) el = elements[0]; if (elements.length >= 2) { showWarning( 'Multiple tags detected. Only the first is ' + 'going to be parsed, all the others will be ignored', ); } this.config = loadConfigFromElement(el); if (this.config.execution_thread === 'worker' && crossOriginIsolated === false) { throw new UserError( ErrorCode.BAD_CONFIG, `When execution_thread is "worker", the site must be cross origin isolated, but crossOriginIsolated is false. To be cross origin isolated, the server must use https and also serve with the following headers: ${JSON.stringify( { 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Opener-Policy': 'same-origin', }, )}. The problem may be that one or both of these are missing. `, ); } logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2)); } _get_base_url(): string { // Note that this requires that pyscript is loaded via a