|
| 1 | +// glowcomm_colab.js — browser side of the Colab (comm-only) VPython frontend. |
| 2 | +// |
| 3 | +// Injected inline by with_colab.py via display(HTML). Loads GlowScript from |
| 4 | +// a CDN (Colab output frames may load external scripts; jsDelivr serves the |
| 5 | +// vpython/vscode-vpython repo's media/ directory), then registers a Jupyter |
| 6 | +// comm target through Colab's shim (google.colab.kernel.comms) and drives |
| 7 | +// glowcomm_host.js: |
| 8 | +// |
| 9 | +// downlink: comm message (msg.data = package | 'trigger') -> fe.handle |
| 10 | +// uplink: pacer -> fe.tick() -> send(events) -> comm.send(events) |
| 11 | +// |
| 12 | +// Colab comm API (probed 2026-08): registerTarget(name, cb); the comm object |
| 13 | +// is {send, close, messages} with messages an async iterator. The kernel may |
| 14 | +// retry the comm open (it can't know when this script has run), so every |
| 15 | +// open is acked and the LATEST comm becomes the active channel. |
| 16 | +// |
| 17 | +// Adaptive pacing: 33 ms while the kernel is answering, easing off to 500 ms |
| 18 | +// when nothing has come back for 2 s (kernel blocked in a long computation |
| 19 | +// or between cells) — bounds the message backlog a blocked kernel must |
| 20 | +// swallow when it wakes. |
| 21 | + |
| 22 | +window.__VPYTHON_COLAB_BOOT = function (opts) { |
| 23 | + 'use strict'; |
| 24 | + var CDN = opts.cdn; // ends with '/' |
| 25 | + var NONCE = opts.nonce; // this session's token; stale saved frames have an old one |
| 26 | + var TICK_MS = 33, SLOW_MS = 500, EASE_AFTER_MS = 2000; |
| 27 | + |
| 28 | + var root = document.getElementById('vpython-colab-root'); |
| 29 | + var status = document.createElement('div'); |
| 30 | + status.style.cssText = 'font-family:monospace;font-size:12px;opacity:.8'; |
| 31 | + var container = document.createElement('div'); |
| 32 | + root.appendChild(status); |
| 33 | + root.appendChild(container); |
| 34 | + |
| 35 | + function setStatus(m) { status.textContent = m; } |
| 36 | + function fail(m) { |
| 37 | + var line = document.createElement('pre'); |
| 38 | + line.textContent = 'VPython (Colab): ' + m; |
| 39 | + line.style.cssText = 'color:#c00;white-space:pre-wrap'; |
| 40 | + root.appendChild(line); |
| 41 | + } |
| 42 | + |
| 43 | + function loadScript(name) { |
| 44 | + return new Promise(function (resolve, reject) { |
| 45 | + var s = document.createElement('script'); |
| 46 | + s.src = CDN + name; |
| 47 | + s.onload = resolve; |
| 48 | + s.onerror = function () { reject(new Error('failed to load ' + CDN + name)); }; |
| 49 | + document.head.appendChild(s); |
| 50 | + }); |
| 51 | + } |
| 52 | + |
| 53 | + // Hide any AMD loader while classic UMD scripts execute (else jQuery UI |
| 54 | + // registers as a module and never patches $.fn — learned in VS Code). |
| 55 | + var hadDefine = Object.prototype.hasOwnProperty.call(window, 'define'); |
| 56 | + var savedDefine = window.define; |
| 57 | + try { window.define = undefined; } catch (e) { } |
| 58 | + function restoreAmd() { |
| 59 | + if (hadDefine) { window.define = savedDefine; } |
| 60 | + else { try { delete window.define; } catch (e) { } } |
| 61 | + } |
| 62 | + |
| 63 | + setStatus('VPython: loading GlowScript from CDN…'); |
| 64 | + loadScript('jquery.min.js') |
| 65 | + .then(function () { return loadScript('jquery-ui.custom.min.js'); }) |
| 66 | + .then(function () { return loadScript('glow.min.js'); }) |
| 67 | + .then(function () { |
| 68 | + window.Jupyter_VPython = CDN + 'data/'; // texture prefix for glow |
| 69 | + function loadFont(file, slot) { |
| 70 | + return new Promise(function (resolve, reject) { |
| 71 | + window.opentype_load(window.Jupyter_VPython + file, function (err, font) { |
| 72 | + if (err) { reject(new Error('font ' + file + ': ' + err)); return; } |
| 73 | + window[slot] = font; |
| 74 | + resolve(); |
| 75 | + }); |
| 76 | + }); |
| 77 | + } |
| 78 | + return Promise.all([ |
| 79 | + loadFont('Roboto-Medium.ttf', '__font_sans'), |
| 80 | + loadFont('NimbusRomNo9L-Med.otf', '__font_serif') |
| 81 | + ]); |
| 82 | + }) |
| 83 | + .then(function () { return loadScript('glowcomm_host.js'); }) |
| 84 | + .then(function () { |
| 85 | + restoreAmd(); |
| 86 | + var jq = window.$ || window.jQuery; |
| 87 | + if (!jq || !jq.fn || typeof jq.fn.resizable !== 'function') { |
| 88 | + throw new Error('jQuery UI did not attach ($.fn.resizable missing)'); |
| 89 | + } |
| 90 | + if (typeof window.createGlowFrontend !== 'function') { |
| 91 | + throw new Error('glowcomm_host loaded but createGlowFrontend missing'); |
| 92 | + } |
| 93 | + |
| 94 | + var active = null, fe = null, lastReply = Date.now(); |
| 95 | + |
| 96 | + function startPacer() { |
| 97 | + (function tickLoop() { |
| 98 | + var idle = Date.now() - lastReply > EASE_AFTER_MS; |
| 99 | + try { fe.tick(); } catch (e) { fail('tick: ' + e); } |
| 100 | + setTimeout(tickLoop, idle ? SLOW_MS : TICK_MS); |
| 101 | + })(); |
| 102 | + } |
| 103 | + |
| 104 | + // Shared by both handshake directions: bind a live comm as the active |
| 105 | + // channel, create the frontend on first use, and pump its messages. |
| 106 | + function useComm(comm, ackFirst) { |
| 107 | + active = comm; // latest wins on both sides |
| 108 | + if (ackFirst) { comm.send({ ack: 1 }); } |
| 109 | + if (!fe) { |
| 110 | + fe = window.createGlowFrontend({ |
| 111 | + container: container, |
| 112 | + glow: window, |
| 113 | + send: function (events) { if (active) { active.send(events); } } |
| 114 | + }); |
| 115 | + startPacer(); |
| 116 | + setStatus(''); |
| 117 | + } |
| 118 | + (async function () { |
| 119 | + try { |
| 120 | + for await (var m of comm.messages) { |
| 121 | + lastReply = Date.now(); |
| 122 | + var d = (m && m.data !== undefined) ? m.data : m; |
| 123 | + fe.handle(d === 'trigger' ? 'trigger' : d); |
| 124 | + } |
| 125 | + } catch (e) { /* iterator ends when comm closes; a newer comm takes over */ } |
| 126 | + })(); |
| 127 | + } |
| 128 | + |
| 129 | + var comms = google.colab.kernel.comms; |
| 130 | + |
| 131 | + // Fallback: kernel-initiated opens (retried from the kernel idle loop). |
| 132 | + comms.registerTarget('vpython-glow', function (comm, openMsg) { |
| 133 | + // Ignore kernel-initiated opens meant for a different session's frame. |
| 134 | + var d = openMsg && (openMsg.data || (openMsg.content && openMsg.content.data)); |
| 135 | + if (d && d.nonce && d.nonce !== NONCE) { return; } |
| 136 | + useComm(comm, true); // ack tells the kernel which comm to attach |
| 137 | + }); |
| 138 | + |
| 139 | + // Preferred: WE open the comm, because only we know when this script |
| 140 | + // has actually finished loading. The kernel registered the target |
| 141 | + // before displaying this bootstrap, so it always exists by now. The |
| 142 | + // kernel processes the open at its next idle moment and flushes the |
| 143 | + // buffered scene. No races, no retries. |
| 144 | + if (typeof comms.open === 'function') { |
| 145 | + Promise.resolve(comms.open('vpython-glow-kernel', { nonce: NONCE })) |
| 146 | + .then(function (comm) { |
| 147 | + if (comm && comm.send && comm.messages) { useComm(comm, true); } |
| 148 | + else { setStatus('VPython: comms.open returned unusable comm; ' + |
| 149 | + 'waiting for kernel-initiated connect…'); } |
| 150 | + }) |
| 151 | + .catch(function (e) { |
| 152 | + setStatus('VPython: comms.open failed (' + e + '); ' + |
| 153 | + 'waiting for kernel-initiated connect…'); |
| 154 | + }); |
| 155 | + setStatus('VPython: ready — connecting to the kernel…'); |
| 156 | + } else { |
| 157 | + setStatus('VPython: ready — waiting for the kernel to connect ' + |
| 158 | + '(run the next cell if this lingers)…'); |
| 159 | + } |
| 160 | + }) |
| 161 | + .catch(function (e) { restoreAmd(); fail((e && e.stack) || String(e)); }); |
| 162 | +}; |
0 commit comments