https://simonwillison.net/2022/May/4/datasette-lite/ Simon Willison's Weblog Datasette Lite: a server-side Python web application running in a browser Datasette Lite is a new way to run Datasette: entirely in a browser, taking advantage of the incredible Pyodide project which provides Python compiled to WebAssembly plus a whole suite of useful extras. You can try it out here: https://lite.datasette.io/ A screenshot of the pypi_packages database table running in Google Chrome in a page with the URL of lite.datasette.io/#/content/ pypi_packages?_facet=author The initial example loads two databases--the classic fixtures.db used by the Datasette test suite, and the content.db database that powers the official datasette.io website (described in some detail in my post about Baked Data). You can instead use the "Load database by URL to a SQLite DB" button to paste in a URL to your own database. That file will need to be served with CORS headers that allow it to be fetched by the website ( see README). Try this URL, for example: https://congress-legislators.datasettes.com/legislators.db You can follow this link to open that database in Datasette Lite. Datasette Lite supports almost all of Datasette's regular functionality: you can view tables, apply facets, run your own custom SQL results and export the results as CSV or JSON. It's basically the full Datasette experience, except it's running entirely in your browser with no server (other than the static file hosting provided here by GitHub Pages) required. I'm pretty stunned that this is possible now. I had to make some small changes to Datasette to get this to work, detailed below, but really nothing extravagant--the demo is running the exact same Python code as the regular server-side Datasette application, just inside a web worker process in a browser rather than on a server. The implementation is pretty small--around 300 lines of JavaScript. You can see the code in the simonw/datasette-lite repository--in two files, index.html and webworker.js Why build this? I built this because I want as many people as possible to be able to use my software. I've invested a ton of effort in reducing the friction to getting started with Datasette. I've documented the install process, I've packaged it for Homebrew, I've written guides to running it on Glitch , I've built tools to help deploy it to Heroku, Cloud Run, Vercel and Fly.io. I even taught myself Electron and built a macOS Datasette Desktop application, so people could install it without having to think about their Python environment. Datasette Lite is my latest attempt at this. Anyone with a browser that can run WebAssembly can now run Datasette in it--if they can afford the 10MB load (which in many places with metered internet access is way too much). I also built this because I'm fascinated by WebAssembly and I've been looking for an opportunity to really try it out. And, I find this project deeply amusing. Running a Python server-side web application in a browser still feels like an absurd thing to do. I love that it works. I'm deeply inspired by JupyterLite. Datasette Lite's name is a tribute to that project. How it works: Python in a Web Worker Datasette Lite does most of its work in a Web Worker--a separate process that can run expensive CPU operations (like an entire Python interpreter) without blocking the main browser's UI thread. The worker starts running when you load the page. It loads a WebAssembly compiled Python interpreter from a CDN, then installs Datasette and its dependencies into that interpreter using micropip. It also downloads the specified SQLite database files using the browser's HTTP fetching mechanism and writes them to a virtual in-memory filesystem managed by Pyodide. Once everything is installed, it imports datasette and creates a Datasette() object called ds. This object stays resident in the web worker. To render pages, the index.html page sends a message to the web worker specifying which Datasette path has been requested--/ for the homepage, /fixtures for the database index page, /fixtures/facetable for a table page and so on. The web worker then simulates an HTTP GET against that path within Datasette using the following code: response = await ds.client.get(path, follow_redirects=True) This takes advantage of a really useful internal Datasette API: datasette.client is an HTTPX client object that can be used to execute HTTP requests against Datasette internally, without doing a round-trip across the network. I initially added datasette.client with the goal of making any JSON APIs that Datasette provides available for internal calls by plugins as well, and to make it easier to write automated tests. It turns out to have other interesting applications too! The web worker sends a message back to index.html with the status code, content type and content retrieved from Datasette. JavaScript in index.html then injects that HTML into the page using .innerHTML. To get internal links working, Datasette Lite uses a trick I originally learned from jQuery: it applies a capturing event listener to the area of the page displaying the content, such that any link clicks or form submissions will be intercepted by a JavaScript function. That JavaScript can then turn them into new messages to the web worker rather than navigating to another page. Some annotated code Here are annotated versions of the most important pieces of code. In index.html this code manages the worker and updates the page when it recieves messages from it: // Load the worker script const datasetteWorker = new Worker("webworker.js"); // Extract the ?url= from the current page's URL const initialUrl = new URLSearchParams(location.search).get('url'); // Message that to the worker: {type: 'startup', initialUrl: url} datasetteWorker.postMessage({type: 'startup', initialUrl}); // This function does most of the work - it responds to messages sent // back from the worker to the index page: datasetteWorker.onmessage = (event) => { // {type: log, line: ...} messages are appended to a log textarea: var ta = document.getElementById('loading-logs'); if (event.data.type == 'log') { loadingLogs.push(event.data.line); ta.value = loadingLogs.join("\n"); ta.scrollTop = ta.scrollHeight; return; } let html = ''; // If it's an {error: ...} message show it in a
in aif (event.data.error) { html = ``; // If contentType is text/html, show it as straight HTML } else if (/^text\/html/.exec(event.data.contentType)) { html = event.data.text; // For contentType of application/json parse and pretty-print it } else if (/^application\/json/.exec(event.data.contentType)) { html = `Error
${escapeHtml(event.data.error)}${escapeHtml(JSON.stringify(JSON.parse(event.data.text), null, 4))}`; // Anything else (likely CSV data) escape it and show in a} else { html = `${escapeHtml(event.data.text)}`; } // Add the result tousing innerHTML document.getElementById("output").innerHTML = html; // Update the document.title if aelement is present let title = document.getElementById("output").querySelector("title"); if (title) { document.title = title.innerText; } // Scroll to the top of the page after each new page is loaded window.scrollTo({top: 0, left: 0}); // If we're showing the initial loading indicator, hide it document.getElementById('loading-indicator').style.display = 'none'; }; The webworker.js script is where the real magic happens: // Load Pyodide from the CDN importScripts("https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js"); // Deliver log messages back to the index.html page function log(line) { self.postMessage({type: 'log', line: line}); } // This function initializes Pyodide and installs Datasette async function startDatasette(initialUrl) { // Mechanism for downloading and saving specified DB files let toLoad = []; if (initialUrl) { let name = initialUrl.split('.db')[0].split('/').slice(-1)[0]; toLoad.push([name, initialUrl]); } else { // If no ?url= provided, loads these two demo databases instead: toLoad.push(["fixtures.db", "https://latest.datasette.io/fixtures.db"]); toLoad.push(["content.db", "https://datasette.io/content.db"]); } // This does a LOT of work - it pulls down the WASM blob and starts it running self.pyodide = await loadPyodide({ indexURL: "https://cdn.jsdelivr.net/pyodide/dev/full/" }); // We need these packages for the next bit of code to work await pyodide.loadPackage('micropip', log); await pyodide.loadPackage('ssl', log); await pyodide.loadPackage('setuptools', log); // For pkg_resources try { // Now we switch to Python code await self.pyodide.runPythonAsync(` # Here's where we download and save those .db files - they are saved # to a virtual in-memory filesystem provided by Pyodide # pyfetch is a wrapper around the JS fetch() function - calls using # it are handled by the browser's regular HTTP fetching mechanism from pyodide.http import pyfetch names = [] for name, url in ${JSON.stringify(toLoad)}: response = await pyfetch(url) with open(name, "wb") as fp: fp.write(await response.bytes()) names.append(name) import micropip # Workaround for Requested 'h11<0.13,>=0.11', but h11==0.13.0 is already installed await micropip.install("h11==0.12.0") # Install Datasette itself! await micropip.install("datasette==0.62a0") # Now we can create a Datasette() object that can respond to fake requests from datasette.app import Datasette ds = Datasette(names, settings={ "num_sql_threads": 0, }, metadata = { # This metadata is displayed in Datasette's footer "about": "Datasette Lite", "about_url": "https://github.com/simonw/datasette-lite" }) `); datasetteLiteReady(); } catch (error) { self.postMessage({error: error.message}); } } // Outside promise pattern // https://github.com/simonw/datasette-lite/issues/25#issuecomment-1116948381 let datasetteLiteReady; let readyPromise = new Promise(function(resolve) { datasetteLiteReady = resolve; }); // This function handles messages sent from index.html to webworker.js self.onmessage = async (event) => { // The first message should be that startup message, carrying the URL if (event.data.type == 'startup') { await startDatasette(event.data.initialUrl); return; } // This promise trick ensures that we don't run the next block until we // are certain that startDatasette() has finished and the ds.client // Python object is ready to use await readyPromise; // Run the reuest in Python to get a status code, content type and text try { let [status, contentType, text] = await self.pyodide.runPythonAsync( ` import json # ds.client.get(path) simulates running a request through Datasette response = await ds.client.get( # Using json here is a quick way to generate a quoted string ${JSON.stringify(event.data.path)}, # If Datasette redirects to another page we want to follow that follow_redirects=True ) [response.status_code, response.headers.get("content-type"), response.text] ` ); // Message the results back to index.html self.postMessage({status, contentType, text}); } catch (error) { // If an error occurred, send that back as a {error: ...} message self.postMessage({error: error.message}); } }; One last bit of code: here's the JavaScript in index.html which intercepts clicks on links and turns them into messages to the worker: let output = document.getElementById('output'); // This captures any click on any element within