asgi-iframe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """Display an ASGI app (FastAPI / Starlette) inside an iframe in a marimo notebook.
2
+
3
+ No sockets and no server: fetch / XMLHttpRequest calls, link navigation and form
4
+ submissions inside the iframe are forwarded to Python over anywidget messages, and
5
+ the app is invoked directly with ``await app(scope, receive, send)``.
6
+
7
+ from asgi_iframe import asgi_iframe
8
+ asgi_iframe(app, "/") # path of an endpoint that returns HTML
9
+ asgi_iframe(app, "<!doctype html>...") # a complete HTML page as a string
10
+
11
+ Subresources such as <script src>, <link rel=stylesheet> and <img> are also loaded
12
+ through the app and swapped for blob: URLs, so FastAPI ``StaticFiles`` mounts work as-is.
13
+ """
14
+
15
+ from ._frame import VIRTUAL_ORIGIN, AsgiFrame, asgi_iframe
16
+
17
+ __all__ = ["AsgiFrame", "VIRTUAL_ORIGIN", "asgi_iframe"]
18
+ __version__ = "0.1.0"
asgi_iframe/_frame.py ADDED
@@ -0,0 +1,587 @@
1
+ """Implementation of the :class:`AsgiFrame` widget and :func:`asgi_iframe`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import threading
8
+ from http.cookies import SimpleCookie
9
+ from typing import Any
10
+ from urllib.parse import unquote, urljoin, urlsplit
11
+
12
+ import anywidget
13
+ import traitlets
14
+
15
+ VIRTUAL_ORIGIN = "http://asgi.local"
16
+ _REDIRECTS = {301, 302, 303, 307, 308}
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Shim injected into the iframe: relays fetch / XHR / <a> / <form> to the parent widget
20
+ # ---------------------------------------------------------------------------
21
+ _SHIM = r"""
22
+ (() => {
23
+ const ORIGIN = "__ORIGIN__";
24
+ const CURRENT = new URL(__PATH__, ORIGIN);
25
+ const realFetch = window.fetch.bind(window);
26
+ const pending = new Map();
27
+ let seq = 0;
28
+
29
+ addEventListener("message", (ev) => {
30
+ const d = ev.data;
31
+ if (ev.source !== parent || !d || !d.__asgi || d.kind !== "response") return;
32
+ const p = pending.get(d.id);
33
+ if (p) { pending.delete(d.id); p(d.res); }
34
+ });
35
+ const call = (kind, req) => new Promise((resolve) => {
36
+ const id = ++seq;
37
+ pending.set(id, resolve);
38
+ parent.postMessage({ __asgi: true, kind, id, req }, "*");
39
+ });
40
+
41
+ // A srcdoc iframe resolves relative URLs against the parent (marimo) origin; map those to the virtual origin
42
+ const resolveUrl = (u) => {
43
+ const url = new URL(u, CURRENT);
44
+ return url.origin === location.origin ? new URL(url.pathname + url.search + url.hash, ORIGIN) : url;
45
+ };
46
+ const isInternal = (url) => url.origin === ORIGIN;
47
+
48
+ const toB64 = (buf) => {
49
+ const bytes = new Uint8Array(buf);
50
+ let s = "";
51
+ for (let i = 0; i < bytes.length; i += 0x8000)
52
+ s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
53
+ return btoa(s);
54
+ };
55
+ const fromB64 = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
56
+
57
+ // Normalize through a Request object (lets the browser handle FormData multipart boundaries etc.)
58
+ async function encode(request, url = request.url) {
59
+ const body = ["GET", "HEAD"].includes(request.method) ? null : toB64(await request.arrayBuffer());
60
+ return { method: request.method, url, headers: [...request.headers], body };
61
+ }
62
+
63
+ // ---- fetch -------------------------------------------------------------
64
+ window.fetch = async (input, init) => {
65
+ const raw = input instanceof Request ? input.url : String(input);
66
+ const url = resolveUrl(raw);
67
+ if (!isInternal(url)) return realFetch(input, init);
68
+ const base = input instanceof Request ? new Request(input, init) : new Request(url.href, init);
69
+ const res = await call("fetch", await encode(base, url.href)); // only the URL is replaced with the virtual one
70
+ if (res.error) throw new TypeError("ASGI: " + res.error);
71
+ const nullBody = [101, 204, 205, 304].includes(res.status);
72
+ const response = new Response(nullBody ? null : fromB64(res.body), {
73
+ status: res.status, statusText: res.statusText, headers: res.headers,
74
+ });
75
+ Object.defineProperty(response, "url", { value: res.url });
76
+ Object.defineProperty(response, "redirected", { value: res.redirected });
77
+ return response;
78
+ };
79
+
80
+ // ---- XMLHttpRequest (for htmx and friends) ------------------------------
81
+ const RealXHR = window.XMLHttpRequest;
82
+ class AsgiXHR extends EventTarget {
83
+ constructor() {
84
+ super();
85
+ this.readyState = 0; this.status = 0; this.statusText = "";
86
+ this.response = null; this.responseText = ""; this.responseURL = "";
87
+ this.responseType = ""; this.timeout = 0; this.withCredentials = false;
88
+ this.upload = new EventTarget();
89
+ this._headers = []; this._resHeaders = []; this._aborted = false; this._real = null;
90
+ }
91
+ _fire(type) {
92
+ const ev = new ProgressEvent(type);
93
+ const h = this["on" + type];
94
+ if (typeof h === "function") h.call(this, ev);
95
+ this.dispatchEvent(ev);
96
+ }
97
+ _state(n) { this.readyState = n; this._fire("readystatechange"); }
98
+ open(method, url, async = true, user, password) {
99
+ const u = resolveUrl(url);
100
+ if (!isInternal(u)) { // external URLs go through the real XHR
101
+ const real = new RealXHR();
102
+ this._real = real;
103
+ for (const k of ["readystatechange", "load", "error", "abort", "timeout", "loadend", "progress", "loadstart"]) {
104
+ real.addEventListener(k, () => {
105
+ for (const p of ["readyState", "status", "statusText", "responseURL"]) this[p] = real[p];
106
+ try { this.response = real.response; } catch {}
107
+ try { this.responseText = real.responseText; } catch {}
108
+ this._fire(k);
109
+ });
110
+ }
111
+ real.open(method, url, async, user, password);
112
+ return;
113
+ }
114
+ this._method = method.toUpperCase(); this._url = u.href;
115
+ this._state(1);
116
+ }
117
+ setRequestHeader(k, v) { this._real ? this._real.setRequestHeader(k, v) : this._headers.push([k, v]); }
118
+ overrideMimeType(m) { this._real && this._real.overrideMimeType(m); }
119
+ getResponseHeader(name) {
120
+ if (this._real) return this._real.getResponseHeader(name);
121
+ const hit = this._resHeaders.filter(([k]) => k.toLowerCase() === name.toLowerCase());
122
+ return hit.length ? hit.map(([, v]) => v).join(", ") : null;
123
+ }
124
+ getAllResponseHeaders() {
125
+ if (this._real) return this._real.getAllResponseHeaders();
126
+ return this._resHeaders.map(([k, v]) => `${k}: ${v}`).join("\r\n");
127
+ }
128
+ abort() {
129
+ if (this._real) return this._real.abort();
130
+ this._aborted = true; this._fire("abort"); this._fire("loadend");
131
+ }
132
+ async send(body = null) {
133
+ if (this._real) { this._real.responseType = this.responseType; this._real.timeout = this.timeout; return this._real.send(body); }
134
+ this._fire("loadstart");
135
+ let res;
136
+ try {
137
+ const init = { method: this._method, headers: this._headers };
138
+ if (body != null && !["GET", "HEAD"].includes(this._method)) init.body = body;
139
+ res = await call("fetch", await encode(new Request(this._url, init)));
140
+ } catch (e) { res = { error: String(e) }; }
141
+ if (this._aborted) return;
142
+ if (res.error) { this._state(4); this._fire("error"); this._fire("loadend"); return; }
143
+ this.status = res.status; this.statusText = res.statusText;
144
+ this.responseURL = res.url; this._resHeaders = res.headers;
145
+ this._state(2); this._state(3);
146
+ const bytes = fromB64(res.body);
147
+ const text = new TextDecoder().decode(bytes);
148
+ this.responseText = text;
149
+ switch (this.responseType) {
150
+ case "json": try { this.response = JSON.parse(text); } catch { this.response = null; } break;
151
+ case "arraybuffer": this.response = bytes.buffer; break;
152
+ case "blob": this.response = new Blob([bytes], { type: this.getResponseHeader("content-type") || "" }); break;
153
+ case "document": this.response = new DOMParser().parseFromString(text, "text/html"); break;
154
+ default: this.response = text;
155
+ }
156
+ this._state(4); this._fire("load"); this._fire("loadend");
157
+ }
158
+ }
159
+ AsgiXHR.UNSENT = 0; AsgiXHR.OPENED = 1; AsgiXHR.HEADERS_RECEIVED = 2; AsgiXHR.LOADING = 3; AsgiXHR.DONE = 4;
160
+ window.XMLHttpRequest = AsgiXHR;
161
+
162
+ // ---- Page navigation (links and forms) ----------------------------------
163
+ const navigate = async (request) => call("navigate", await encode(request));
164
+
165
+ document.addEventListener("click", (ev) => {
166
+ if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
167
+ const a = ev.target.closest && ev.target.closest("a[href]");
168
+ if (!a || (a.target && a.target !== "_self") || a.hasAttribute("download")) return;
169
+ const href = a.getAttribute("href");
170
+ if (href.startsWith("#") || href.startsWith("javascript:")) return;
171
+ const url = resolveUrl(href);
172
+ if (!isInternal(url)) { ev.preventDefault(); window.open(url.href, "_blank", "noopener"); return; }
173
+ ev.preventDefault();
174
+ navigate(new Request(url.href));
175
+ });
176
+
177
+ document.addEventListener("submit", (ev) => {
178
+ if (ev.defaultPrevented) return;
179
+ const form = ev.target;
180
+ const submitter = ev.submitter;
181
+ const action = resolveUrl(submitter?.getAttribute("formaction") || form.getAttribute("action") || CURRENT.href);
182
+ if (!isInternal(action)) return;
183
+ ev.preventDefault();
184
+ const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase();
185
+ const data = new FormData(form, submitter);
186
+ if (method === "GET") {
187
+ action.search = new URLSearchParams(data).toString();
188
+ navigate(new Request(action.href));
189
+ } else {
190
+ const enctype = submitter?.getAttribute("formenctype") || form.getAttribute("enctype") || "application/x-www-form-urlencoded";
191
+ const body = enctype === "multipart/form-data" ? data : new URLSearchParams(data);
192
+ navigate(new Request(action.href, { method, body }));
193
+ }
194
+ });
195
+
196
+ // ---- Load dynamically inserted img / link / source etc. through the app ----
197
+ const ATTR = { IMG: "src", SOURCE: "src", VIDEO: "src", AUDIO: "src", TRACK: "src", INPUT: "src", LINK: "href" };
198
+ const fix = async (el) => {
199
+ const attr = ATTR[el.tagName];
200
+ if (!attr || (el.tagName === "INPUT" && el.type !== "image")) return;
201
+ if (el.tagName === "LINK" && !/stylesheet|icon|preload/i.test(el.rel)) return;
202
+ const raw = el.getAttribute(attr);
203
+ if (!raw || /^(blob:|data:|#)/i.test(raw)) return;
204
+ const url = resolveUrl(raw);
205
+ if (!isInternal(url)) return;
206
+ const res = await call("resource", { url: url.href });
207
+ if (res.url && el.getAttribute(attr) === raw) el.setAttribute(attr, res.url);
208
+ };
209
+ new MutationObserver((muts) => {
210
+ for (const m of muts) {
211
+ if (m.type === "attributes") { fix(m.target); continue; }
212
+ for (const n of m.addedNodes) {
213
+ if (n.nodeType !== 1) continue;
214
+ fix(n);
215
+ n.querySelectorAll?.("img,source,video,audio,track,input,link").forEach(fix);
216
+ }
217
+ }
218
+ }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["src", "href"] });
219
+ })();
220
+ """
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Widget-side JS: creates the iframe and bridges shim postMessages with Python messages
224
+ # ---------------------------------------------------------------------------
225
+ _ESM = r"""
226
+ const SHIM = __SHIM__;
227
+ const ORIGIN = "__ORIGIN__";
228
+ const NULL_BODY = [101, 204, 205, 304];
229
+ const fromB64 = (b64) => Uint8Array.from(atob(b64 || ""), (c) => c.charCodeAt(0));
230
+ const header = (res, name) => (res.headers.find(([k]) => k.toLowerCase() === name) || [, ""])[1];
231
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
232
+
233
+ // Return the virtual URL if the reference targets the ASGI app (not external / special schemes)
234
+ function internalUrl(raw, base) {
235
+ if (!raw || /^(data:|blob:|javascript:|about:|#)/i.test(raw.trim())) return null;
236
+ let url;
237
+ try { url = new URL(raw, base); } catch { return null; }
238
+ if (url.origin === location.origin) url = new URL(url.pathname + url.search + url.hash, ORIGIN);
239
+ return url.origin === ORIGIN ? url : null;
240
+ }
241
+
242
+ function render({ model, el }) {
243
+ const wrap = document.createElement("div");
244
+ wrap.style.cssText = "display:flex;flex-direction:column;border:1px solid #8884;border-radius:6px;overflow:hidden";
245
+ const bar = document.createElement("div");
246
+ bar.style.cssText = "display:flex;gap:6px;align-items:center;padding:3px 6px;font:12px ui-monospace,monospace;background:#8881";
247
+ const back = document.createElement("button"); back.textContent = "←";
248
+ const reload = document.createElement("button"); reload.textContent = "⟳";
249
+ for (const b of [back, reload]) b.style.cssText = "border:none;background:none;cursor:pointer;font-size:13px;padding:0 4px";
250
+ const addr = document.createElement("span"); addr.style.cssText = "opacity:.7;overflow:hidden;white-space:nowrap;text-overflow:ellipsis";
251
+ bar.append(back, reload, addr);
252
+ const iframe = document.createElement("iframe");
253
+ iframe.style.cssText = "width:100%;border:none;background:white";
254
+ iframe.style.height = model.get("height") + "px";
255
+ wrap.append(bar, iframe);
256
+ if (!model.get("toolbar")) bar.style.display = "none";
257
+ el.appendChild(wrap);
258
+
259
+ // ---- Round trips to Python ---------------------------------------------
260
+ const pending = new Map();
261
+ let seq = 0;
262
+ const request = (req) => new Promise((resolve) => {
263
+ const id = ++seq;
264
+ pending.set(id, resolve);
265
+ model.send({ type: "request", id, req });
266
+ });
267
+ model.on("msg:custom", (msg) => {
268
+ if (msg.type !== "response") return;
269
+ const p = pending.get(msg.id);
270
+ if (p) { pending.delete(msg.id); p(msg.res); }
271
+ });
272
+ const get = (url) => request({ method: "GET", url, headers: [], body: null });
273
+
274
+ // ---- Turn subresources (script / link / img / CSS url()) into blob: URLs ----
275
+ let blobs = new Map(); // virtual URL -> Promise<blob URL> (per page)
276
+ async function rewriteCss(css, base) {
277
+ const refs = new Set();
278
+ css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/g, (_, _q, a, _q2, b) => { refs.add(a || b); return _; });
279
+ const map = new Map();
280
+ await Promise.all([...refs].map(async (ref) => {
281
+ const u = internalUrl(ref, base);
282
+ if (u) { const b = await resource(u.href); if (b) map.set(ref, b); }
283
+ }));
284
+ return css
285
+ .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, ref) => map.has(ref) ? `url("${map.get(ref)}")` : m)
286
+ .replace(/@import\s+(['"])([^'"]+)\1/g, (m, q, ref) => map.has(ref) ? `@import "${map.get(ref)}"` : m);
287
+ }
288
+ function resource(url) {
289
+ if (!blobs.has(url)) {
290
+ blobs.set(url, (async () => {
291
+ const res = await get(url);
292
+ if (res.error || res.status >= 400) { console.warn("[asgi_iframe] resource failed:", url, res.status ?? res.error); return null; }
293
+ const type = header(res, "content-type");
294
+ let data = fromB64(res.body);
295
+ if (type.includes("text/css")) data = await rewriteCss(new TextDecoder().decode(data), res.url);
296
+ return URL.createObjectURL(new Blob([data], { type }));
297
+ })());
298
+ }
299
+ return blobs.get(url);
300
+ }
301
+ const SUBRESOURCES = [
302
+ ["script[src]", "src"], ["img[src]", "src"], ["source[src]", "src"], ["video[src]", "src"],
303
+ ["video[poster]", "poster"], ["audio[src]", "src"], ["track[src]", "src"], ["input[type=image][src]", "src"],
304
+ ["link[href]:is([rel~=stylesheet i],[rel~=icon i],[rel~=preload i],[rel~=modulepreload i])", "href"],
305
+ ];
306
+ async function prepare(html, path) {
307
+ const page = new URL(path, ORIGIN).href;
308
+ const doc = new DOMParser().parseFromString(html, "text/html");
309
+ const jobs = [];
310
+ for (const [sel, attr] of SUBRESOURCES) {
311
+ for (const node of doc.querySelectorAll(sel)) {
312
+ const u = internalUrl(node.getAttribute(attr), page);
313
+ if (u) jobs.push(resource(u.href).then((b) => b && node.setAttribute(attr, b)));
314
+ }
315
+ }
316
+ for (const style of doc.querySelectorAll("style")) {
317
+ jobs.push(rewriteCss(style.textContent, page).then((css) => { style.textContent = css; }));
318
+ }
319
+ await Promise.all(jobs);
320
+ const shim = doc.createElement("script");
321
+ shim.textContent = SHIM.replace("__PATH__", () => JSON.stringify(path));
322
+ doc.head.prepend(shim);
323
+ const doctype = doc.doctype ? new XMLSerializer().serializeToString(doc.doctype) : "<!doctype html>";
324
+ return doctype + "\n" + doc.documentElement.outerHTML;
325
+ }
326
+
327
+ // ---- Rendering and navigation -------------------------------------------
328
+ const history = [];
329
+ let current = null; // { html, path } (keeps the HTML before rewriting)
330
+ let token = 0;
331
+ async function show(html, path, push = true) {
332
+ const my = ++token;
333
+ if (push && current) history.push(current);
334
+ current = { html, path };
335
+ addr.textContent = path;
336
+ back.disabled = history.length === 0;
337
+ const old = blobs;
338
+ blobs = new Map();
339
+ const doc = await prepare(html, path);
340
+ if (my !== token) return;
341
+ iframe.srcdoc = doc;
342
+ setTimeout(() => old.forEach((p) => p.then((u) => u && URL.revokeObjectURL(u))), 5000);
343
+ }
344
+ const errorPage = (title, detail) => `<!doctype html><html><head><meta charset="utf-8"></head>
345
+ <body style="font-family:sans-serif;padding:1em"><h3>${esc(title)}</h3><pre style="white-space:pre-wrap">${esc(detail)}</pre></body></html>`;
346
+ const pathOf = (url) => { const u = new URL(url); return u.pathname + u.search + u.hash; };
347
+
348
+ async function navigate(req, push = true) {
349
+ const res = await request(req);
350
+ if (res.error) return show(errorPage("ASGI error", res.error), pathOf(req.url), push);
351
+ const text = new TextDecoder().decode(fromB64(res.body));
352
+ const ctype = header(res, "content-type");
353
+ let html;
354
+ if (ctype.includes("html")) html = text;
355
+ else if (res.status >= 400) html = errorPage(`${res.status} ${res.statusText}`, text);
356
+ else html = `<!doctype html><html><head><meta charset="utf-8"></head><body><pre style="white-space:pre-wrap">${esc(text)}</pre></body></html>`;
357
+ return show(html, pathOf(res.url), push);
358
+ }
359
+
360
+ const onMessage = async (ev) => {
361
+ if (ev.source !== iframe.contentWindow) return;
362
+ const d = ev.data;
363
+ if (!d || !d.__asgi) return;
364
+ let res = {};
365
+ if (d.kind === "fetch") res = await request(d.req);
366
+ else if (d.kind === "navigate") await navigate(d.req);
367
+ else if (d.kind === "resource") res = { url: await resource(d.req.url) };
368
+ iframe.contentWindow?.postMessage({ __asgi: true, kind: "response", id: d.id, res }, "*");
369
+ };
370
+ window.addEventListener("message", onMessage);
371
+
372
+ back.onclick = () => { const prev = history.pop(); if (prev) show(prev.html, prev.path, false); };
373
+ reload.onclick = () => {
374
+ if (!current) return;
375
+ if (model.get("html") && history.length === 0) show(model.get("html"), current.path, false);
376
+ else navigate({ method: "GET", url: new URL(current.path, ORIGIN).href, headers: [], body: null }, false);
377
+ };
378
+
379
+ const start = () => {
380
+ history.length = 0; current = null;
381
+ const html = model.get("html"), path = model.get("path");
382
+ if (html) show(html, path, false);
383
+ else navigate({ method: "GET", url: new URL(path, ORIGIN).href, headers: [], body: null }, false);
384
+ };
385
+ model.on("change:html", start);
386
+ model.on("change:path", start);
387
+ model.on("change:height", () => { iframe.style.height = model.get("height") + "px"; });
388
+ start();
389
+
390
+ return () => {
391
+ window.removeEventListener("message", onMessage);
392
+ blobs.forEach((p) => p.then((u) => u && URL.revokeObjectURL(u)));
393
+ };
394
+ }
395
+ export default { render };
396
+ """
397
+
398
+
399
+ def _build_esm() -> str:
400
+ import json
401
+
402
+ shim = _SHIM.replace("__ORIGIN__", VIRTUAL_ORIGIN)
403
+ return _ESM.replace("__SHIM__", json.dumps(shim)).replace('"__ORIGIN__"', json.dumps(VIRTUAL_ORIGIN))
404
+
405
+
406
+ class AsgiFrame(anywidget.AnyWidget):
407
+ """anywidget that renders an ASGI app in an iframe without sockets."""
408
+
409
+ _esm = _build_esm()
410
+ html = traitlets.Unicode("").tag(sync=True)
411
+ path = traitlets.Unicode("/").tag(sync=True)
412
+ height = traitlets.Int(500).tag(sync=True)
413
+ toolbar = traitlets.Bool(True).tag(sync=True)
414
+
415
+ def __init__(self, app: Any, page: str = "/", *, height: int = 500,
416
+ toolbar: bool = True, lifespan: bool = True, **kwargs: Any) -> None:
417
+ is_html = page.lstrip().startswith("<")
418
+ super().__init__(html=page if is_html else "", path="/" if is_html else page,
419
+ height=height, toolbar=toolbar, **kwargs)
420
+ self._app = app
421
+ self._use_lifespan = lifespan
422
+ self._lifespan_ready: asyncio.Future | None = None
423
+ self._lifespan_state: dict[str, Any] = {}
424
+ self._cookies: dict[str, str] = {}
425
+ self._loop: asyncio.AbstractEventLoop | None = None
426
+ self.on_msg(self._on_msg)
427
+
428
+ # ---- Incoming messages -> schedule async handling ----------------------
429
+ def _on_msg(self, _widget: Any, content: dict, _buffers: list) -> None:
430
+ if not isinstance(content, dict) or content.get("type") != "request":
431
+ return
432
+ coro = self._respond(content["id"], content["req"])
433
+ try:
434
+ asyncio.get_running_loop().create_task(coro) # marimo kernel / Pyodide loop
435
+ except RuntimeError:
436
+ asyncio.run_coroutine_threadsafe(coro, self._background_loop())
437
+
438
+ def _background_loop(self) -> asyncio.AbstractEventLoop:
439
+ # Dedicated loop for when no loop is running (keeps app state bound to one loop)
440
+ if self._loop is None:
441
+ self._loop = asyncio.new_event_loop()
442
+ threading.Thread(target=self._loop.run_forever, daemon=True).start()
443
+ return self._loop
444
+
445
+ async def _respond(self, msg_id: int, req: dict) -> None:
446
+ try:
447
+ res = await self.request(req["method"], req["url"], req.get("headers") or [],
448
+ base64.b64decode(req["body"]) if req.get("body") else b"")
449
+ except Exception as e: # noqa: BLE001 show app exceptions in the page
450
+ import traceback
451
+ res = {"error": "".join(traceback.format_exception(e))}
452
+ self.send({"type": "response", "id": msg_id, "res": res})
453
+
454
+ # ---- lifespan ----------------------------------------------------------
455
+ async def _ensure_lifespan(self) -> None:
456
+ if not self._use_lifespan:
457
+ return
458
+ if self._lifespan_ready is None:
459
+ loop = asyncio.get_running_loop()
460
+ self._lifespan_ready = loop.create_future()
461
+ queue: asyncio.Queue = asyncio.Queue()
462
+ queue.put_nowait({"type": "lifespan.startup"})
463
+ ready = self._lifespan_ready
464
+
465
+ async def receive() -> dict:
466
+ return await queue.get() # never send shutdown (lives as long as the widget)
467
+
468
+ async def send(message: dict) -> None:
469
+ if message["type"] == "lifespan.startup.failed" and not ready.done():
470
+ ready.set_exception(RuntimeError(message.get("message", "startup failed")))
471
+ elif message["type"] == "lifespan.startup.complete" and not ready.done():
472
+ ready.set_result(None)
473
+
474
+ scope = {"type": "lifespan", "asgi": {"version": "3.0", "spec_version": "2.0"},
475
+ "state": self._lifespan_state}
476
+
477
+ async def run() -> None:
478
+ try:
479
+ await self._app(scope, receive, send)
480
+ except Exception: # noqa: BLE001 app without lifespan support
481
+ pass
482
+ if not ready.done():
483
+ ready.set_result(None)
484
+
485
+ self._lifespan_task = loop.create_task(run())
486
+ await self._lifespan_ready
487
+
488
+ # ---- A single request (follows redirects, keeps cookies) ---------------
489
+ async def request(self, method: str, url: str, headers: list, body: bytes = b"") -> dict:
490
+ await self._ensure_lifespan()
491
+ redirected = False
492
+ for _ in range(20):
493
+ status, res_headers, res_body = await self._call(method, url, headers, body)
494
+ location = next((v for k, v in res_headers if k.lower() == "location"), None)
495
+ if status in _REDIRECTS and location:
496
+ url = urljoin(url, location)
497
+ redirected = True
498
+ if status == 303 or (status in (301, 302) and method != "HEAD"):
499
+ method, body = ("GET" if method != "HEAD" else "HEAD"), b""
500
+ headers = [(k, v) for k, v in headers if k.lower() not in ("content-type", "content-length")]
501
+ if urlsplit(url).netloc != urlsplit(VIRTUAL_ORIGIN).netloc:
502
+ break # stop at redirects to external hosts
503
+ continue
504
+ break
505
+ from http import HTTPStatus
506
+ try:
507
+ reason = HTTPStatus(status).phrase
508
+ except ValueError:
509
+ reason = ""
510
+ return {"status": status, "statusText": reason, "headers": res_headers,
511
+ "body": base64.b64encode(res_body).decode(), "url": url, "redirected": redirected}
512
+
513
+ async def _call(self, method: str, url: str, headers: list, body: bytes):
514
+ parts = urlsplit(url)
515
+ raw_path = parts.path or "/"
516
+ hdrs = [(k.lower().encode("latin-1"), str(v).encode("latin-1")) for k, v in headers
517
+ if k.lower() not in ("host", "cookie", "content-length")]
518
+ hdrs.append((b"host", parts.netloc.encode()))
519
+ if body:
520
+ hdrs.append((b"content-length", str(len(body)).encode()))
521
+ if self._cookies:
522
+ hdrs.append((b"cookie", "; ".join(f"{k}={v}" for k, v in self._cookies.items()).encode()))
523
+ scope = {
524
+ "type": "http", "asgi": {"version": "3.0", "spec_version": "2.3"}, "http_version": "1.1",
525
+ "method": method, "scheme": parts.scheme, "path": unquote(raw_path),
526
+ "raw_path": raw_path.encode(), "query_string": parts.query.encode(), "root_path": "",
527
+ "headers": hdrs, "server": (parts.hostname, parts.port or 80), "client": ("marimo", 0),
528
+ "state": dict(self._lifespan_state),
529
+ }
530
+ sent = False
531
+ done = asyncio.Event()
532
+ status = 500
533
+ res_headers: list[tuple[str, str]] = []
534
+ chunks: list[bytes] = []
535
+
536
+ async def receive() -> dict:
537
+ nonlocal sent
538
+ if not sent:
539
+ sent = True
540
+ return {"type": "http.request", "body": body, "more_body": False}
541
+ await done.wait()
542
+ return {"type": "http.disconnect"}
543
+
544
+ async def send(message: dict) -> None:
545
+ nonlocal status
546
+ if message["type"] == "http.response.start":
547
+ status = message["status"]
548
+ for k, v in message.get("headers", []):
549
+ res_headers.append((k.decode("latin-1"), v.decode("latin-1")))
550
+ elif message["type"] == "http.response.body":
551
+ chunks.append(message.get("body", b""))
552
+ if not message.get("more_body", False):
553
+ done.set()
554
+
555
+ try:
556
+ await self._app(scope, receive, send)
557
+ finally:
558
+ done.set()
559
+ for k, v in res_headers:
560
+ if k.lower() == "set-cookie":
561
+ for name, morsel in SimpleCookie(v).items():
562
+ if morsel["max-age"] == "0" or (morsel["expires"] and "1970" in morsel["expires"]):
563
+ self._cookies.pop(name, None)
564
+ else:
565
+ self._cookies[name] = morsel.value
566
+ return status, res_headers, b"".join(chunks)
567
+
568
+
569
+ def asgi_iframe(app: Any, page: str = "/", *, height: int = 500,
570
+ toolbar: bool = True, lifespan: bool = True):
571
+ """Show a page of an ASGI app in an iframe inside a marimo notebook.
572
+
573
+ Args:
574
+ app: An ASGI application (FastAPI, Starlette, ...).
575
+ page: A path such as ``"/"`` is fetched with GET and its HTML is shown;
576
+ a string starting with ``"<"`` is shown as HTML directly.
577
+ height: Height of the iframe in pixels.
578
+ toolbar: Show the small toolbar with back / reload buttons and the current path.
579
+ lifespan: Run the app's lifespan startup before the first request.
580
+
581
+ fetch / XHR calls, same-origin links and forms, and subresources such as
582
+ <script src>, <link> and <img> inside the page are all passed straight to ``app``
583
+ without opening a socket.
584
+ """
585
+ import marimo as mo
586
+
587
+ return mo.ui.anywidget(AsgiFrame(app, page, height=height, toolbar=toolbar, lifespan=lifespan))
asgi_iframe/py.typed ADDED
File without changes
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: asgi-iframe
3
+ Version: 0.1.0
4
+ Summary: Show a FastAPI / Starlette (ASGI) app in an iframe inside marimo notebooks, without sockets or a server.
5
+ Project-URL: Homepage, https://github.com/uniras/asgi-iframe
6
+ Author: riafeed
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: anywidget,asgi,fastapi,iframe,marimo,molab,notebook,starlette
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: anywidget>=0.9
19
+ Requires-Dist: traitlets>=5
20
+ Provides-Extra: marimo
21
+ Requires-Dist: marimo>=0.9; extra == 'marimo'
22
+ Provides-Extra: test
23
+ Requires-Dist: fastapi>=0.100; extra == 'test'
24
+ Requires-Dist: pytest>=7; extra == 'test'
25
+ Requires-Dist: python-multipart; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # asgi-iframe
29
+
30
+ Show a FastAPI / Starlette (any ASGI) app in an iframe inside a [marimo](https://marimo.io) notebook — **without opening a socket or starting a server**.
31
+
32
+ Handy where you can't run `uvicorn` and open a port, such as [molab](https://molab.marimo.io) or other hosted notebooks.
33
+
34
+ ```python
35
+ from fastapi import FastAPI
36
+ from fastapi.responses import HTMLResponse
37
+ from asgi_iframe import asgi_iframe
38
+
39
+ app = FastAPI()
40
+
41
+ @app.get("/api/hello")
42
+ def hello(name: str = "World"):
43
+ return {"message": f"Hello, {name}!"}
44
+
45
+ @app.get("/", response_class=HTMLResponse)
46
+ def index():
47
+ return """<!doctype html><html><body>
48
+ <button onclick="fetch('/api/hello?name=marimo').then(r => r.json())
49
+ .then(j => this.textContent = j.message)">Say hello</button>
50
+ </body></html>"""
51
+
52
+ asgi_iframe(app, "/") # last expression of a cell -> rendered as output
53
+ ```
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install asgi-iframe
59
+ ```
60
+
61
+ The runtime dependencies are `anywidget` and `traitlets`. marimo itself is expected to be installed already (or use `pip install "asgi-iframe[marimo]"`).
62
+
63
+ ## Usage
64
+
65
+ ```python
66
+ asgi_iframe(app, "/") # GET an endpoint and show the HTML it returns
67
+ asgi_iframe(app, "<!doctype html>...") # show an HTML string; its requests still go to `app`
68
+ asgi_iframe(app, "/", height=600, toolbar=False, lifespan=True)
69
+ ```
70
+
71
+ | Argument | Default | Description |
72
+ |------------|---------|-------------|
73
+ | `app` | | Any ASGI application (FastAPI, Starlette, ...). |
74
+ | `page` | `"/"` | A path is fetched with GET; a string starting with `<` is used as the HTML itself. |
75
+ | `height` | `500` | Height of the iframe in pixels. |
76
+ | `toolbar` | `True` | Show a small bar with back / reload buttons and the current path. |
77
+ | `lifespan` | `True` | Run the app's lifespan startup before the first request (`request.state` works). |
78
+
79
+ `asgi_iframe()` returns `mo.ui.anywidget(AsgiFrame(...))`. The underlying `AsgiFrame` widget is also exported.
80
+
81
+ ### What is routed to the app
82
+
83
+ Everything that targets the page's own origin:
84
+
85
+ - `fetch()` and `XMLHttpRequest` (so htmx works)
86
+ - link clicks and form submissions (GET / POST, urlencoded or multipart) — the page is replaced in the iframe
87
+ - subresources: `<script src>`, `<link rel=stylesheet|icon|preload|modulepreload>`, `<img>`, `<source>`, `<video>`, `<audio>`, `<track>`, plus `url()` / `@import` in stylesheets and `<style>` blocks
88
+ - elements inserted later (e.g. by htmx swaps): `<img>`, `<link>`, `<source>`, ...
89
+
90
+ Redirects are followed and cookies set by the app are kept for the lifetime of the widget. Absolute URLs to other hosts are left to the browser.
91
+
92
+ ### Static files
93
+
94
+ Serve them from the app, exactly as you would in production:
95
+
96
+ ```python
97
+ import marimo as mo
98
+ from fastapi.staticfiles import StaticFiles
99
+
100
+ app.mount("/static", StaticFiles(directory=mo.notebook_dir() / "static"), name="static")
101
+ ```
102
+
103
+ ```html
104
+ <link rel="stylesheet" href="/static/style.css">
105
+ <script src="/static/app.js"></script>
106
+ ```
107
+
108
+ ## How it works
109
+
110
+ An ASGI app is just an async callable, `await app(scope, receive, send)`. `uvicorn` only translates socket bytes into those calls, so this package replaces the socket with anywidget's message channel:
111
+
112
+ 1. The page is rendered into an `<iframe srcdoc>` together with a small shim script.
113
+ 2. The shim overrides `fetch` / `XMLHttpRequest` and intercepts navigation; requests for the virtual origin `http://asgi.local` are posted to the widget.
114
+ 3. The widget sends them to Python, which builds an ASGI `scope` and calls the app directly on the running event loop.
115
+ 4. The response is sent back and turned into a `Response` / XHR result, or into a new page. Subresources become `blob:` URLs.
116
+
117
+ ## Limitations
118
+
119
+ - Responses are buffered: streaming responses arrive all at once, and SSE / WebSocket are not supported.
120
+ - Relative `import` statements inside JavaScript modules don't resolve (scripts are loaded from `blob:` URLs). Bundle your modules or use absolute CDN URLs.
121
+ - `srcset` and `<script src>` elements inserted after the page has loaded are not rewritten.
122
+ - The lifespan shutdown event is never sent; re-running the cell creates a new widget.
123
+ - `document.cookie` in the page does not see the app's cookies.
124
+ - Developed and tested with marimo (local server). Running in marimo's WebAssembly mode and in Jupyter has not been tested.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,7 @@
1
+ asgi_iframe/__init__.py,sha256=iRyHyAuNMMgXAmULS0cbWn3iAv8oXzdITp4P32zucLg,841
2
+ asgi_iframe/_frame.py,sha256=xP9bTuvAEppBMa01tsDNAqimo8ZYQvtJc6A9tWvDB8A,27553
3
+ asgi_iframe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ asgi_iframe-0.1.0.dist-info/METADATA,sha256=DwR4iRdXK_mHdwV95mUtMNPJpGhxzxT3D65ROSyZH5w,5457
5
+ asgi_iframe-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ asgi_iframe-0.1.0.dist-info/licenses/LICENSE,sha256=jLeyEUtC7Kgytb7ozTpI9Ww7gdc-1SZHMS6xod3FVaE,1064
7
+ asgi_iframe-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RiaFeed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.