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.
- asgi_iframe/__init__.py +18 -0
- asgi_iframe/_frame.py +587 -0
- asgi_iframe/py.typed +0 -0
- asgi_iframe-0.1.0.dist-info/METADATA +128 -0
- asgi_iframe-0.1.0.dist-info/RECORD +7 -0
- asgi_iframe-0.1.0.dist-info/WHEEL +4 -0
- asgi_iframe-0.1.0.dist-info/licenses/LICENSE +21 -0
asgi_iframe/__init__.py
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">" }[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,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.
|