|
16 | 16 | "If the red square renders and the WebSocket echo answers back, the full round-trip works." |
17 | 17 | ] |
18 | 18 | }, |
| 19 | + { |
| 20 | + "cell_type": "markdown", |
| 21 | + "id": "892ad7e3", |
| 22 | + "metadata": {}, |
| 23 | + "source": [ |
| 24 | + "## 0. Enable the comm bridge (for VS Code and other non-jupyter-server frontends)\n", |
| 25 | + "\n", |
| 26 | + "JupyterLab / Hub / Binder can reach the loopback port through the HTTP proxy mounted by the server extension (step 2), so this step is not strictly required there. But for VS Code Jupyter (local or SSH), Colab, Shiny, Solara, and marimo, the notebook webview has no way to reach the jupyter-server origin for root-relative URLs. Enabling the comm bridge gives the browser a second, always-available path to the kernel's loopback port over the kernel's comm channel.\n", |
| 27 | + "\n", |
| 28 | + "Once enabled, `window.__jupyter_loopback__` is installed in the output pane and exposes `fetch(port, path)`, `resolveUrl(port, path, {mime})`, and `openWebSocket(port, path)`. The HTML cell below feature-detects those APIs and prefers them; otherwise it falls back to the direct URL that the HTTP proxy handles." |
| 29 | + ] |
| 30 | + }, |
| 31 | + { |
| 32 | + "cell_type": "code", |
| 33 | + "execution_count": null, |
| 34 | + "id": "7d433484", |
| 35 | + "metadata": {}, |
| 36 | + "outputs": [], |
| 37 | + "source": [ |
| 38 | + "import jupyter_loopback\n", |
| 39 | + "\n", |
| 40 | + "jupyter_loopback.enable_comm_bridge()" |
| 41 | + ] |
| 42 | + }, |
19 | 43 | { |
20 | 44 | "cell_type": "markdown", |
21 | 45 | "id": "env", |
|
219 | 243 | "id": "browser", |
220 | 244 | "metadata": {}, |
221 | 245 | "source": [ |
222 | | - "## 6. Browser round-trip through the proxy\n", |
| 246 | + "## 6. Browser round-trip\n", |
| 247 | + "\n", |
| 248 | + "The HTML below feature-detects `window.__jupyter_loopback__`:\n", |
223 | 249 | "\n", |
224 | | - "The HTML below uses `http_url` / `ws_url` from step 4. The browser hits the Jupyter server, the extension routes to `127.0.0.1:<port>`. Three checks:\n", |
| 250 | + "- If the comm bridge is enabled (step 0), the browser fetches `/hello`, `/image.png`, and opens the WebSocket **through the kernel comm channel** via `api.fetch`, `api.resolveUrl`, and `api.openWebSocket`. This is the path that works in VS Code Jupyter (including Remote-SSH), Colab, Shiny, Solara, and marimo.\n", |
| 251 | + "- Otherwise it uses the direct URL built in step 4, which goes through the jupyter-server's HTTP proxy handler. This is the faster path when it's available (JupyterLab, Hub, Binder, Notebook 7+).\n", |
225 | 252 | "\n", |
226 | | - "- **JSON link**: opens `/hello` in a new tab.\n", |
227 | | - "- **Inline image**: 1×1 red PNG, CSS-upscaled to 80×80 with `image-rendering: pixelated`. If you see a red square, binary bodies survive proxying.\n", |
| 253 | + "Three checks regardless of path:\n", |
| 254 | + "\n", |
| 255 | + "- **JSON fetch**: text of `GET /hello` appears inline.\n", |
| 256 | + "- **Inline image**: 1×1 red PNG, CSS-upscaled to 80×80. If you see a red square, binary bodies survive the round-trip.\n", |
228 | 257 | "- **WebSocket echo**: type a message, press Enter. Server echoes back prefixed with a count." |
229 | 258 | ] |
230 | 259 | }, |
|
237 | 266 | "source": [ |
238 | 267 | "from IPython.display import HTML\n", |
239 | 268 | "\n", |
240 | | - "html = f'''\n", |
| 269 | + "# Pick a path: if autodetect produced a prefix (JupyterLab / Hub) we can\n", |
| 270 | + "# fetch directly through the Path A proxy. Otherwise we'll still compute\n", |
| 271 | + "# a direct loopback URL as a fallback, but the HTML below prefers the\n", |
| 272 | + "# comm bridge (window.__jupyter_loopback__) when it's available.\n", |
| 273 | + "http_fallback = http_url\n", |
| 274 | + "ws_fallback = ws_url\n", |
| 275 | + "\n", |
| 276 | + "html = f\"\"\"\n", |
241 | 277 | "<div style=\"border:1px solid #ddd;border-radius:6px;padding:12px;\n", |
242 | 278 | " font-family:system-ui,sans-serif;max-width:640px\">\n", |
243 | 279 | " <p style=\"margin:0 0 8px\">Bound to <code>127.0.0.1:{port}</code> in the kernel.</p>\n", |
244 | 280 | " <ul style=\"margin:0 0 8px\">\n", |
245 | | - " <li><a href=\"{http_url}/hello\" target=\"_blank\"><code>GET {http_url}/hello</code></a></li>\n", |
246 | | - " <li><code>GET {http_url}/image.png</code>:\n", |
247 | | - " <img src=\"{http_url}/image.png\" alt=\"red square\"\n", |
| 281 | + " <li><code>GET /hello</code> <span id=\"lpbk-hello-{port}\">(loading...)</span></li>\n", |
| 282 | + " <li><code>GET /image.png</code>\n", |
| 283 | + " <img id=\"lpbk-img-{port}\" alt=\"red square\"\n", |
248 | 284 | " style=\"vertical-align:middle;width:80px;height:80px;\n", |
249 | 285 | " image-rendering:pixelated;border:1px solid #ccc;\n", |
250 | 286 | " border-radius:4px;margin-left:6px\"/></li>\n", |
251 | | - " <li><code>WS {ws_url}</code></li>\n", |
| 287 | + " <li><code>WS /ws</code> (<span id=\"lpbk-mode-{port}\">detecting...</span>)</li>\n", |
252 | 288 | " </ul>\n", |
253 | 289 | " <input id=\"lpbk-in-{port}\" type=\"text\" placeholder=\"type, press Enter\"\n", |
254 | 290 | " style=\"width:100%;padding:6px;border:1px solid #ccc;border-radius:4px\"/>\n", |
255 | 291 | " <pre id=\"lpbk-log-{port}\"\n", |
256 | 292 | " style=\"margin-top:8px;background:#f7f7f7;padding:8px;border-radius:4px;\n", |
257 | 293 | " max-height:140px;overflow:auto\"></pre>\n", |
258 | 294 | " <script>\n", |
259 | | - " (function() {{\n", |
260 | | - " var wsPath = {json.dumps(ws_url)};\n", |
261 | | - " var abs = new URL(wsPath, window.location.origin);\n", |
262 | | - " abs.protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n", |
263 | | - " var input = document.getElementById(\"lpbk-in-{port}\");\n", |
264 | | - " var log = document.getElementById(\"lpbk-log-{port}\");\n", |
265 | | - " var ws = new WebSocket(abs.toString());\n", |
266 | | - " ws.onopen = function() {{ log.textContent += \"[open \" + abs + \"]\\\\n\"; }};\n", |
267 | | - " ws.onclose = function() {{ log.textContent += \"[close]\\\\n\"; }};\n", |
268 | | - " ws.onerror = function() {{ log.textContent += \"[error]\\\\n\"; }};\n", |
269 | | - " ws.onmessage = function(ev) {{\n", |
| 295 | + " (async function() {{\n", |
| 296 | + " var port = {port};\n", |
| 297 | + " var httpFallback = {json.dumps(http_fallback)};\n", |
| 298 | + " var wsFallback = {json.dumps(ws_fallback)};\n", |
| 299 | + " var api = window.__jupyter_loopback__;\n", |
| 300 | + " var useBridge = !!(api && api.resolveUrl && api.openWebSocket);\n", |
| 301 | + "\n", |
| 302 | + " var log = document.getElementById(\"lpbk-log-\" + port);\n", |
| 303 | + " var img = document.getElementById(\"lpbk-img-\" + port);\n", |
| 304 | + " var helloSpan = document.getElementById(\"lpbk-hello-\" + port);\n", |
| 305 | + " var modeSpan = document.getElementById(\"lpbk-mode-\" + port);\n", |
| 306 | + " modeSpan.textContent = useBridge ? \"comm bridge\" : \"direct\";\n", |
| 307 | + "\n", |
| 308 | + " // GET /hello\n", |
| 309 | + " try {{\n", |
| 310 | + " if (useBridge) {{\n", |
| 311 | + " var resp = await api.fetch(port, \"/hello\");\n", |
| 312 | + " var text = await resp.text();\n", |
| 313 | + " helloSpan.textContent = text;\n", |
| 314 | + " }} else {{\n", |
| 315 | + " var resp2 = await fetch(httpFallback + \"/hello\");\n", |
| 316 | + " helloSpan.textContent = await resp2.text();\n", |
| 317 | + " }}\n", |
| 318 | + " }} catch (err) {{\n", |
| 319 | + " helloSpan.textContent = \"error: \" + err;\n", |
| 320 | + " }}\n", |
| 321 | + "\n", |
| 322 | + " // GET /image.png\n", |
| 323 | + " try {{\n", |
| 324 | + " if (useBridge) {{\n", |
| 325 | + " img.src = await api.resolveUrl(port, \"/image.png\", {{mime: \"image/png\"}});\n", |
| 326 | + " }} else {{\n", |
| 327 | + " img.src = httpFallback + \"/image.png\";\n", |
| 328 | + " }}\n", |
| 329 | + " }} catch (err) {{\n", |
| 330 | + " log.textContent += \"[image error: \" + err + \"]\\\\n\";\n", |
| 331 | + " }}\n", |
| 332 | + "\n", |
| 333 | + " // WS /ws\n", |
| 334 | + " var ws;\n", |
| 335 | + " if (useBridge) {{\n", |
| 336 | + " ws = api.openWebSocket(port, \"/ws\");\n", |
| 337 | + " }} else {{\n", |
| 338 | + " var abs = new URL(wsFallback, window.location.origin);\n", |
| 339 | + " abs.protocol = window.location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n", |
| 340 | + " ws = new WebSocket(abs.toString());\n", |
| 341 | + " }}\n", |
| 342 | + " ws.onopen = function() {{ log.textContent += \"[open]\\\\n\"; }};\n", |
| 343 | + " ws.onclose = function() {{ log.textContent += \"[close]\\\\n\"; }};\n", |
| 344 | + " ws.onerror = function(e) {{ log.textContent += \"[error]\\\\n\"; }};\n", |
| 345 | + " ws.onmessage = function(ev) {{\n", |
270 | 346 | " log.textContent += \"< \" + ev.data + \"\\\\n\";\n", |
271 | 347 | " log.scrollTop = log.scrollHeight;\n", |
272 | 348 | " }};\n", |
| 349 | + "\n", |
| 350 | + " var input = document.getElementById(\"lpbk-in-\" + port);\n", |
273 | 351 | " input.addEventListener(\"keydown\", function(e) {{\n", |
274 | 352 | " if (e.key !== \"Enter\") return;\n", |
275 | 353 | " var v = input.value; input.value = \"\";\n", |
|
280 | 358 | " }})();\n", |
281 | 359 | " </script>\n", |
282 | 360 | "</div>\n", |
283 | | - "'''\n", |
| 361 | + "\"\"\"\n", |
284 | 362 | "\n", |
285 | 363 | "HTML(html)" |
286 | 364 | ] |
|
306 | 384 | "thread.join(timeout=5)\n", |
307 | 385 | "print(\"stopped\")" |
308 | 386 | ] |
| 387 | + }, |
| 388 | + { |
| 389 | + "cell_type": "code", |
| 390 | + "execution_count": null, |
| 391 | + "id": "5dce253c", |
| 392 | + "metadata": {}, |
| 393 | + "outputs": [], |
| 394 | + "source": [] |
309 | 395 | } |
310 | 396 | ], |
311 | 397 | "metadata": { |
312 | 398 | "kernelspec": { |
313 | | - "display_name": "Python 3", |
| 399 | + "display_name": "jupyter-loopback", |
314 | 400 | "language": "python", |
315 | 401 | "name": "python3" |
| 402 | + }, |
| 403 | + "language_info": { |
| 404 | + "codemirror_mode": { |
| 405 | + "name": "ipython", |
| 406 | + "version": 3 |
| 407 | + }, |
| 408 | + "file_extension": ".py", |
| 409 | + "mimetype": "text/x-python", |
| 410 | + "name": "python", |
| 411 | + "nbconvert_exporter": "python", |
| 412 | + "pygments_lexer": "ipython3", |
| 413 | + "version": "3.14.3" |
316 | 414 | } |
317 | 415 | }, |
318 | 416 | "nbformat": 4, |
|
0 commit comments