Building a VS Code Extension: Running Python in the Browser with Webview and Pyodide
Creating a Visual Studio Code extension extends the functionality of the popular code editor, allowing developers to integrate custom tools, languages, and workflows directly within the IDE. While VS Code extensions are typically written in TypeScript or JavaScript and run in a Node.js environment (the “extension host”), there are scenarios where executing complex logic, especially code written in other languages like Python, becomes necessary. Directly embedding and running a full Python environment within the Node.js extension host is generally impractical due to dependencies, environment management, and potential conflicts.
A powerful pattern to address this involves using a Webview. A Webview is essentially an embedded browser panel within VS Code that can display standard web content (HTML, CSS, JavaScript). This provides a sandboxed environment, separate from the extension host, where rich user interfaces and web technologies can be leveraged.
Combining a Webview with Pyodide offers a compelling solution for running Python code. Pyodide is a distribution of CPython compiled to WebAssembly, allowing Python to run directly in modern web browsers. By integrating Pyodide into a Webview, a VS Code extension can create an environment where Python code execution is facilitated client-side, within the isolated browser context of the Webview, orchestrated by the extension running in the Node.js host.
This approach enables the creation of extensions with interactive UIs that utilize Python logic and libraries, without requiring a local Python installation on the user’s machine or complex inter-process communication between the extension host and a separate Python process.
Essential Concepts
Understanding the core components and their interaction is crucial for building such an extension.
VS Code Extension Architecture
VS Code extensions operate in an “extension host” process, which is a Node.js environment separate from the main VS Code UI process.
- Extensions are defined by a
package.jsonmanifest file specifying activation events (when the extension loads) and contributions (commands, menus, views). - The core logic resides in a main file (often
extension.tsorextension.js), which is loaded when an activation event occurs. - Extensions interact with VS Code and the workspace via the
vscodeAPI. - Commands are a primary way users trigger extension functionality.
Webviews in VS Code
Webviews provide a mechanism for extensions to create custom views or editors using standard web technologies.
- They display arbitrary HTML content within a panel in the VS Code window.
- Each Webview runs in an isolated context, similar to an
<iframe>or a separate browser tab, with restricted access to the user’s machine for security. - Communication between the extension host (Node.js) and the Webview (JavaScript in the browser) is facilitated exclusively through message passing using the
postMessageAPI. Data sent must be serializable JSON. - Local resources (like CSS, JavaScript, images) needed by the Webview must be exposed by the extension using special URIs obtained via
webview.asWebviewUri.
Pyodide
Pyodide brings the Python interpreter to the browser environment.
- It compiles the CPython interpreter and standard library to WebAssembly.
- It allows running Python code directly within a web page’s JavaScript environment.
- Pyodide provides seamless interoperability between Python and JavaScript, allowing Python to access the DOM and JavaScript objects, and vice-versa.
- It supports loading many pure-Python packages from PyPI, and some packages with C extensions that have been compiled to WebAssembly.
The Interaction Model
The combined approach involves these steps:
- An activation event triggers the extension.
- A user action (e.g., executing a command) prompts the extension to create a new Webview panel.
- The extension sets the HTML content for the Webview. This HTML file typically loads necessary CSS and JavaScript resources.
- The Webview’s JavaScript loads the Pyodide environment (fetching the WebAssembly files and Python standard library).
- Once Pyodide is ready, the Webview is capable of executing Python code.
- The extension host can send Python code (as a string) to the Webview via
postMessage. - The Webview’s JavaScript receives the message, passes the Python code string to the Pyodide interpreter (
pyodide.runPythonAsync). - Pyodide executes the code. Output (like
printstatements) and results are captured by the Webview’s JavaScript. - The Webview’s JavaScript sends the execution results and any errors back to the extension host via
postMessage. - The extension host receives the result message and can update the VS Code UI, display output in an output channel, or perform other actions.
This message-passing architecture ensures security and separation of concerns between the trusted extension host environment and the less-trusted Webview browser environment.
Step-by-Step Walkthrough: Building a Basic Pyodide Webview Extension
This section outlines the process for creating a simple VS Code extension that opens a Webview capable of running Python code using Pyodide.
Prerequisites
- Node.js and npm installed.
- VS Code installed.
- Yeoman and the VS Code Extension Generator installed globally:
Terminal window npm install -g yo generator-code
Project Setup
- Run the VS Code Extension Generator:
Terminal window yo code - Select “New Extension (TypeScript)”.
- Follow the prompts: Enter extension name (e.g.,
pyodide-webview-demo), identifier, description, author, and select whether to initialize a Git repository. - Open the generated project in VS Code (
code pyodide-webview-demo).
The core files generated include package.json (extension manifest), src/extension.ts (main extension code), and tsconfig.json (TypeScript configuration).
Creating the Webview Files
Create a directory (e.g., media) in the project root to hold the Webview’s static files. Inside media, create three files: index.html, script.js, and style.css.
-
media/index.html: The structure of the Webview’s page. This will include placeholders for loading scripts and styles that the extension will replace with correctwebviewUris.<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Pyodide Console</title><link rel="stylesheet" href="%styleUri%"></head><body><h1>Pyodide Web Console</h1><textarea id="python-code" rows="10" cols="80">print("Hello from Pyodide!")</textarea><button id="run-button">Run Python</button><pre id="output"></pre><script src="https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"></script><script src="%scriptUri%"></script></body></html>%styleUri%and%scriptUri%are placeholders.- The Pyodide script is loaded directly from a CDN.
-
media/style.css: Basic styling for the Webview content.body {font-family: sans-serif;padding: 20px;}#output {background-color: #f0f0f0;padding: 10px;white-space: pre-wrap;word-wrap: break-word;max-height: 300px;overflow-y: auto;} -
media/script.js: The JavaScript code that runs inside the Webview. This handles loading Pyodide, receiving messages from the extension, sending messages back, and interacting with the HTML elements.const vscode = acquireVsCodeApi();let pyodide;async function loadPyodideAndRun() {console.log("Loading Pyodide...");try {pyodide = await loadPyodide({indexURL: "https://cdn.jsdelivr.net/pyodide/v0.25.0/full/"});console.log("Pyodide loaded.");appendToOutput("Pyodide ready.\n");} catch (err) {appendToOutput(`Error loading Pyodide: ${err}\n`, true);console.error("Error loading Pyodide:", err);}}async function runPythonCode(code) {if (!pyodide) {appendToOutput("Pyodide is not loaded yet.\n", true);return;}appendToOutput(`>>> ${code}\n`);try {// Redirect stdout/stderr to capture print outputawait pyodide.runPythonAsync(`import sysimport iosys.stdout = io.StringIO()sys.stderr = io.StringIO()`);await pyodide.runPythonAsync(code);const stdout = pyodide.globals.get("sys").stdout.getvalue();const stderr = pyodide.globals.get("sys").stderr.getvalue();appendToOutput(stdout);if (stderr) {appendToOutput(stderr, true);}// Send result back to extension (optional for simple console)// vscode.postMessage({ type: 'pythonResult', result: stdout + stderr });} catch (err) {appendToOutput(`Error: ${err}\n`, true);// Send error back to extension// vscode.postMessage({ type: 'pythonError', error: err.toString() });console.error("Error running Python:", err);} finally {// Reset stdout/stderrawait pyodide.runPythonAsync(`sys.stdout = sys.__stdout__sys.stderr = sys.__stderr__`);}}function appendToOutput(text, isError = false) {const outputDiv = document.getElementById('output');const span = document.createElement('span');span.style.color = isError ? 'red' : 'black';span.textContent = text;outputDiv.appendChild(span);outputDiv.scrollTop = outputDiv.scrollHeight; // Auto-scroll}// Handle messages from the extension hostwindow.addEventListener('message', async event => {const message = event.data; // The JSON data sent from the extensionswitch (message.type) {case 'runCode':runPythonCode(message.code);break;// Add other message types as needed}});// Handle button click to run code from the textareadocument.getElementById('run-button').addEventListener('click', () => {const code = document.getElementById('python-code').value;runPythonCode(code);});// Initial loadloadPyodideAndRun();acquireVsCodeApi()is a global function available in Webviews to get the messaging API.pyodide.runPythonAsyncis used for executing Python code, important for async operations like loading packages.sys.stdoutandsys.stderrare redirected within Pyodide to capture printed output and errors.
Implementing Extension Logic (src/extension.ts)
Modify src/extension.ts to register a command that creates and manages the Webview panel.
- Import the
vscodemodule. - Register a command in the
activatefunction. - Inside the command handler:
- Create a new
vscode.WebviewPanel. - Set its
htmlproperty by calling a function that loadsindex.htmland replaces the placeholder URIs. - Handle messages received from the Webview using
panel.webview.onDidReceiveMessage. - Optionally handle panel disposal using
panel.onDidDispose.
- Create a new
import * as vscode from 'vscode';import * as path from 'path';import * as fs from 'fs';
export function activate(context: vscode.ExtensionContext) {
console.log('Congratulations, extension "pyodide-webview-demo" is now active!');
let disposable = vscode.commands.registerCommand('pyodide-webview-demo.startConsole', () => { // Create and show a new webview panel const panel = vscode.window.createWebviewPanel( 'pyodideConsole', // Identifies the type of the webview. Used internally 'Pyodide Web Console', // Title of the panel displayed to the user vscode.ViewColumn.One, // Editor column to show the new panel in. { // Enable javascript in the webview enableScripts: true, // Restrict the webview to only loading content from our extension's `media` directory. localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))] } );
// Get the path to the media directory const mediaPath = path.join(context.extensionPath, 'media');
// Get URIs for the webview const scriptUri = panel.webview.asWebviewUri(vscode.Uri.file(path.join(mediaPath, 'script.js'))); const styleUri = panel.webview.asWebviewUri(vscode.Uri.file(path.join(mediaPath, 'style.css')));
// Load the HTML content let htmlContent = fs.readFileSync(path.join(mediaPath, 'index.html'), 'utf8');
// Replace placeholders with actual URIs htmlContent = htmlContent.replace('%scriptUri%', scriptUri.toString()); htmlContent = htmlContent.replace('%styleUri%', styleUri.toString());
// Set the HTML content panel.webview.html = htmlContent;
// Handle messages from the webview panel.webview.onDidReceiveMessage( message => { switch (message.type) { case 'pythonResult': // Handle results received from the webview's python execution console.log('Python Result:', message.result); // Example: Display in an output channel // const outputChannel = vscode.window.createOutputChannel('Pyodide Output'); // outputChannel.appendLine('Result: ' + message.result); // outputChannel.show(); return; case 'pythonError': // Handle errors console.error('Python Error:', message.error); // Example: Display error in an output channel // const outputChannel = vscode.window.createOutputChannel('Pyodide Output'); // outputChannel.appendLine('Error: ' + message.error); // outputChannel.show(); return; } }, undefined, context.subscriptions );
// Example: Send a message to the webview after it's created (optional) // setTimeout(() => { // panel.webview.postMessage({ type: 'runCode', code: 'print("Message from extension!")' }); // }, 2000); });
context.subscriptions.push(disposable);}
// This method is called when your extension is deactivatedexport function deactivate() {}vscode.window.createWebviewPanelcreates the panel.enableScripts: trueis necessary for the JavaScript (script.js) to run within the Webview.localResourceRootsis crucial for security, specifying which local directories the Webview is allowed to load resources from (likemedia).panel.webview.asWebviewUriconverts a local file path into a special URI (vscode-resource://...or similar) that the Webview can safely load.panel.webview.onDidReceiveMessagesets up the handler for incoming messages from the Webview.
Testing the Extension
- Press
F5in VS Code. This compiles the TypeScript extension and opens a new Extension Development Host window. - In the Extension Development Host window, open the command palette (
Ctrl+Shift+PorCmd+Shift+P). - Search for and run the command registered by the extension (e.g., “Pyodide Webview Demo: Start Console”).
- A new panel should open containing the HTML content. Once Pyodide loads (check the browser’s developer console for “Pyodide ready.”), you can type Python code in the text area and click “Run Python”.
- Output should appear in the
<pre id="output">block within the Webview.
Concrete Example: Enhancing the Console
The basic console demonstrates the core communication and execution flow. This concept can be extended to more complex scenarios.
Example: Running a Python Script from the Workspace
Instead of just a textarea, the extension could allow running a Python script open in the editor.
-
Extension Side:
- Modify the command or create a new one (e.g., “Run Active Python File in Web Console”).
- In the command handler, get the active text editor’s document and its content (
vscode.window.activeTextEditor.document.getText()). - If the Webview panel is already open, send the script content to it via
panel.webview.postMessage({ type: 'runCode', code: scriptContent }). - If the panel is not open, create it first, then send the code once it’s initialized (perhaps wait for a “Pyodide ready” message back from the Webview, or send it immediately and have the Webview queue it).
-
Webview Side (
script.js):- Ensure the
window.addEventListener('message', ...)handler correctly processes therunCodemessage type. - The
runPythonCodefunction already takes the code as an argument, so it can directly execute the receivedscriptContent.
- Ensure the
This enhancement allows users to leverage the Webview’s Pyodide environment to run specific Python files, potentially within a controlled or sandboxed context provided by the Webview, separate from their main system’s Python installation. This is useful for running scripts that rely on specific Pyodide-compatible libraries or need to interact with a browser-like environment.
Considerations for Complex Examples:
- Package Loading: Pyodide can load packages (
await pyodide.loadPackage(...)). The extension could send messages to the Webview to install necessary packages before running a script. - Input: Handling user input (
input()) in the Python code running in the Webview requires capturing the request in Pyodide, sending a message to the extension asking for input, and sending the user’s response back to Pyodide. - State: Managing state or persistent variables between Python executions requires careful handling of the Pyodide interpreter’s global scope or passing data explicitly.
Key Takeaways
- VS Code extensions can leverage Webviews to create rich, custom user interfaces using web technologies (HTML, CSS, JavaScript).
- Pyodide enables running the Python interpreter and many Python libraries directly within the browser environment of a Webview.
- This combination allows VS Code extensions to execute Python code client-side within an isolated sandbox, removing dependencies on a local Python installation or complex inter-process communication.
- Communication between the extension host (Node.js) and the Webview (browser JS) is strictly through message passing (
postMessage). - Local resources needed by the Webview must be explicitly exposed by the extension using
webview.asWebviewUrifor security. - This architecture is suitable for tasks like creating interactive consoles, visualizing data using browser-compatible Python libraries, running simple scripts in a controlled environment, or building educational tools within VS Code.
- Performance, bundle size (due to Pyodide), and the asynchronous nature of communication and Python execution within the Webview are important considerations for complex applications.