Skip to content

Commit c10da04

Browse files
committed
Fix in vscode ssh sessions
1 parent 734ba53 commit c10da04

9 files changed

Lines changed: 1649 additions & 68 deletions

File tree

demos/example.ipynb

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,30 @@
1616
"If the red square renders and the WebSocket echo answers back, the full round-trip works."
1717
]
1818
},
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+
},
1943
{
2044
"cell_type": "markdown",
2145
"id": "env",
@@ -219,12 +243,17 @@
219243
"id": "browser",
220244
"metadata": {},
221245
"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",
223249
"\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",
225252
"\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",
228257
"- **WebSocket echo**: type a message, press Enter. Server echoes back prefixed with a count."
229258
]
230259
},
@@ -237,39 +266,88 @@
237266
"source": [
238267
"from IPython.display import HTML\n",
239268
"\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",
241277
"<div style=\"border:1px solid #ddd;border-radius:6px;padding:12px;\n",
242278
" font-family:system-ui,sans-serif;max-width:640px\">\n",
243279
" <p style=\"margin:0 0 8px\">Bound to <code>127.0.0.1:{port}</code> in the kernel.</p>\n",
244280
" <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",
248284
" style=\"vertical-align:middle;width:80px;height:80px;\n",
249285
" image-rendering:pixelated;border:1px solid #ccc;\n",
250286
" 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",
252288
" </ul>\n",
253289
" <input id=\"lpbk-in-{port}\" type=\"text\" placeholder=\"type, press Enter\"\n",
254290
" style=\"width:100%;padding:6px;border:1px solid #ccc;border-radius:4px\"/>\n",
255291
" <pre id=\"lpbk-log-{port}\"\n",
256292
" style=\"margin-top:8px;background:#f7f7f7;padding:8px;border-radius:4px;\n",
257293
" max-height:140px;overflow:auto\"></pre>\n",
258294
" <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",
270346
" log.textContent += \"< \" + ev.data + \"\\\\n\";\n",
271347
" log.scrollTop = log.scrollHeight;\n",
272348
" }};\n",
349+
"\n",
350+
" var input = document.getElementById(\"lpbk-in-\" + port);\n",
273351
" input.addEventListener(\"keydown\", function(e) {{\n",
274352
" if (e.key !== \"Enter\") return;\n",
275353
" var v = input.value; input.value = \"\";\n",
@@ -280,7 +358,7 @@
280358
" }})();\n",
281359
" </script>\n",
282360
"</div>\n",
283-
"'''\n",
361+
"\"\"\"\n",
284362
"\n",
285363
"HTML(html)"
286364
]
@@ -306,13 +384,33 @@
306384
"thread.join(timeout=5)\n",
307385
"print(\"stopped\")"
308386
]
387+
},
388+
{
389+
"cell_type": "code",
390+
"execution_count": null,
391+
"id": "5dce253c",
392+
"metadata": {},
393+
"outputs": [],
394+
"source": []
309395
}
310396
],
311397
"metadata": {
312398
"kernelspec": {
313-
"display_name": "Python 3",
399+
"display_name": "jupyter-loopback",
314400
"language": "python",
315401
"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"
316414
}
317415
},
318416
"nbformat": 4,

jupyter_loopback/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CommBridge,
1919
RequestHandler,
2020
enable_comm_bridge,
21+
intercept_localhost,
2122
is_comm_bridge_enabled,
2223
off_request,
2324
on_request,
@@ -30,6 +31,7 @@
3031
"RequestHandler",
3132
"autodetect_prefix",
3233
"enable_comm_bridge",
34+
"intercept_localhost",
3335
"is_comm_bridge_enabled",
3436
"is_in_jupyter_kernel",
3537
"off_request",

0 commit comments

Comments
 (0)