@tlockney/cdp-kernel 0.1.1 → 0.3.0

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.
Files changed (44) hide show
  1. package/README.md +274 -21
  2. package/dist/bun-target.d.ts +58 -0
  3. package/dist/bun-target.js +231 -0
  4. package/dist/celld-target.d.ts +39 -0
  5. package/dist/celld-target.js +274 -0
  6. package/dist/deno-config.d.ts +20 -0
  7. package/dist/deno-config.js +130 -0
  8. package/dist/globals-deno-compat.d.ts +21 -0
  9. package/dist/globals-deno-compat.js +10 -0
  10. package/dist/globals.d.ts +36 -0
  11. package/dist/globals.js +15 -0
  12. package/dist/kernel.d.ts +2 -1
  13. package/dist/kernel.js +126 -11
  14. package/dist/main.js +5 -5
  15. package/dist/package.d.ts +13 -5
  16. package/dist/package.js +33 -8
  17. package/dist/target.d.ts +78 -7
  18. package/dist/target.js +67 -12
  19. package/dist/transform.d.ts +25 -1
  20. package/dist/transform.js +150 -11
  21. package/dist/workerd-target.d.ts +123 -0
  22. package/dist/workerd-target.js +450 -0
  23. package/dist/workers-project.d.ts +27 -0
  24. package/dist/workers-project.js +195 -0
  25. package/dist/ws-client.d.ts +32 -0
  26. package/dist/ws-client.js +166 -0
  27. package/package.json +27 -4
  28. package/runtime/bootstrap.mjs +78 -7
  29. package/runtime/bun-channel.d.mts +3 -0
  30. package/runtime/bun-channel.mjs +58 -0
  31. package/runtime/comm.d.mts +46 -0
  32. package/runtime/comm.mjs +108 -0
  33. package/runtime/deno-compat.mjs +1 -0
  34. package/runtime/display.d.mts +11 -0
  35. package/runtime/display.mjs +11 -1
  36. package/runtime/evaluator.d.mts +18 -0
  37. package/runtime/evaluator.mjs +83 -0
  38. package/runtime/node-hooks.d.mts +11 -0
  39. package/runtime/node-hooks.mjs +24 -12
  40. package/runtime/node-modules-resolve.d.mts +8 -0
  41. package/runtime/node-modules-resolve.mjs +106 -0
  42. package/runtime/widget.d.mts +40 -0
  43. package/runtime/widget.mjs +208 -0
  44. package/runtime/workerd-worker.mjs +189 -0
@@ -0,0 +1,32 @@
1
+ import { Buffer } from "node:buffer";
2
+ export declare const OP_CONTINUATION = 0;
3
+ export declare const OP_TEXT = 1;
4
+ export declare const OP_CLOSE = 8;
5
+ export declare const OP_PING = 9;
6
+ export declare const OP_PONG = 10;
7
+ export interface Frame {
8
+ fin: boolean;
9
+ opcode: number;
10
+ payload: Buffer;
11
+ }
12
+ /** One frame; clients must mask what they send. */
13
+ export declare function encodeFrame(opcode: number, payload: Buffer, mask?: Buffer | null): Buffer;
14
+ /** Splits a byte stream into frames, holding back a partial one. */
15
+ export declare class FrameDecoder {
16
+ #private;
17
+ push(chunk: Buffer): Frame[];
18
+ }
19
+ export declare function acceptKey(key: string): string;
20
+ export interface WsChannel {
21
+ send(text: string): void;
22
+ onMessage(listener: (text: string) => void): void;
23
+ onClose(listener: () => void): void;
24
+ close(): void;
25
+ }
26
+ /** Opens a WebSocket over the Unix socket at `socketPath`. */
27
+ export declare function connectUnixWebSocket(socketPath: string, { path, headers }?: {
28
+ path?: string;
29
+ headers?: {
30
+ [name: string]: string;
31
+ };
32
+ }): Promise<WsChannel>;
@@ -0,0 +1,166 @@
1
+ // A minimal WebSocket client (RFC 6455) over a Unix socket, for the workerd
2
+ // target's session with its driver Worker (#26). The WebSocket that Node
3
+ // and Deno provide can't connect to a Unix socket, and a TCP listener would
4
+ // be reachable by every local process. Text messages only: that's all the
5
+ // session carries.
6
+ import { Buffer } from "node:buffer";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { connect } from "node:net";
9
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
+ export const OP_CONTINUATION = 0x0;
11
+ export const OP_TEXT = 0x1;
12
+ export const OP_CLOSE = 0x8;
13
+ export const OP_PING = 0x9;
14
+ export const OP_PONG = 0xa;
15
+ /** One frame; clients must mask what they send. */
16
+ export function encodeFrame(opcode, payload, mask = randomBytes(4)) {
17
+ const len = payload.length;
18
+ const head = len < 126 ? 2 : len < 0x10000 ? 4 : 10;
19
+ const out = Buffer.alloc(head + (mask ? 4 : 0) + len);
20
+ out[0] = 0x80 | opcode;
21
+ const maskBit = mask ? 0x80 : 0;
22
+ if (len < 126) {
23
+ out[1] = maskBit | len;
24
+ }
25
+ else if (len < 0x10000) {
26
+ out[1] = maskBit | 126;
27
+ out.writeUInt16BE(len, 2);
28
+ }
29
+ else {
30
+ out[1] = maskBit | 127;
31
+ out.writeBigUInt64BE(BigInt(len), 2);
32
+ }
33
+ if (mask) {
34
+ mask.copy(out, head);
35
+ for (let i = 0; i < len; i++)
36
+ out[head + 4 + i] = payload[i] ^ mask[i & 3];
37
+ }
38
+ else {
39
+ payload.copy(out, head);
40
+ }
41
+ return out;
42
+ }
43
+ /** Splits a byte stream into frames, holding back a partial one. */
44
+ export class FrameDecoder {
45
+ #buffer = Buffer.alloc(0);
46
+ push(chunk) {
47
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
48
+ const frames = [];
49
+ for (;;) {
50
+ const b = this.#buffer;
51
+ if (b.length < 2)
52
+ break;
53
+ const masked = (b[1] & 0x80) !== 0;
54
+ let len = b[1] & 0x7f;
55
+ let offset = 2;
56
+ if (len === 126) {
57
+ if (b.length < 4)
58
+ break;
59
+ len = b.readUInt16BE(2);
60
+ offset = 4;
61
+ }
62
+ else if (len === 127) {
63
+ if (b.length < 10)
64
+ break;
65
+ len = Number(b.readBigUInt64BE(2));
66
+ offset = 10;
67
+ }
68
+ const maskAt = offset;
69
+ if (masked)
70
+ offset += 4;
71
+ if (b.length < offset + len)
72
+ break;
73
+ const payload = Buffer.from(b.subarray(offset, offset + len));
74
+ if (masked)
75
+ for (let i = 0; i < len; i++)
76
+ payload[i] ^= b[maskAt + (i & 3)];
77
+ frames.push({ fin: (b[0] & 0x80) !== 0, opcode: b[0] & 0x0f, payload });
78
+ this.#buffer = b.subarray(offset + len);
79
+ }
80
+ return frames;
81
+ }
82
+ }
83
+ export function acceptKey(key) {
84
+ return createHash("sha1").update(key + GUID).digest("base64");
85
+ }
86
+ /** Opens a WebSocket over the Unix socket at `socketPath`. */
87
+ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } = {}) {
88
+ return new Promise((resolve, reject) => {
89
+ const socket = connect({ path: socketPath });
90
+ const key = randomBytes(16).toString("base64");
91
+ const messageListeners = [];
92
+ const closeListeners = [];
93
+ let open = false;
94
+ let closed = false;
95
+ let head = Buffer.alloc(0);
96
+ const decoder = new FrameDecoder();
97
+ let parts = [];
98
+ const finish = () => {
99
+ if (closed)
100
+ return;
101
+ closed = true;
102
+ if (!open)
103
+ reject(new Error("the WebSocket closed before it opened"));
104
+ for (const listener of closeListeners)
105
+ listener();
106
+ };
107
+ const write = (opcode, payload) => {
108
+ if (!closed)
109
+ socket.write(encodeFrame(opcode, payload));
110
+ };
111
+ const channel = {
112
+ send: (text) => write(OP_TEXT, Buffer.from(text)),
113
+ onMessage: (listener) => void messageListeners.push(listener),
114
+ onClose: (listener) => void closeListeners.push(listener),
115
+ close() {
116
+ write(OP_CLOSE, Buffer.alloc(0));
117
+ socket.end();
118
+ },
119
+ };
120
+ const onFrames = (frames) => {
121
+ for (const frame of frames) {
122
+ if (frame.opcode === OP_TEXT || frame.opcode === OP_CONTINUATION) {
123
+ parts.push(frame.payload);
124
+ if (frame.fin) {
125
+ const text = Buffer.concat(parts).toString("utf8");
126
+ parts = [];
127
+ for (const listener of messageListeners)
128
+ listener(text);
129
+ }
130
+ }
131
+ else if (frame.opcode === OP_PING) {
132
+ write(OP_PONG, frame.payload);
133
+ }
134
+ else if (frame.opcode === OP_CLOSE) {
135
+ socket.end();
136
+ }
137
+ }
138
+ };
139
+ socket.on("data", (chunk) => {
140
+ if (open)
141
+ return onFrames(decoder.push(chunk));
142
+ head = Buffer.concat([head, chunk]);
143
+ const end = head.indexOf("\r\n\r\n");
144
+ if (end === -1)
145
+ return;
146
+ const response = head.subarray(0, end).toString("latin1");
147
+ const accept = /^sec-websocket-accept:\s*(\S+)/im.exec(response)?.[1];
148
+ if (!/^HTTP\/1\.1 101 /.test(response) || accept !== acceptKey(key)) {
149
+ socket.destroy();
150
+ reject(new Error(`the WebSocket handshake failed: ${response.split("\r\n")[0]}`));
151
+ return;
152
+ }
153
+ open = true;
154
+ resolve(channel);
155
+ onFrames(decoder.push(head.subarray(end + 4)));
156
+ });
157
+ socket.on("error", (err) => {
158
+ if (!open)
159
+ reject(err);
160
+ });
161
+ socket.on("close", finish);
162
+ const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
163
+ socket.write(`GET ${path} HTTP/1.1\r\nHost: cdpk\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n` +
164
+ `Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n${extra}\r\n`);
165
+ });
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on any V8 runtime over the Chrome DevTools Protocol",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -8,7 +8,16 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/tlockney/cdp-kernel.git"
10
10
  },
11
- "keywords": ["jupyter", "kernel", "typescript", "notebook", "deno", "node", "cdp", "devtools-protocol"],
11
+ "keywords": [
12
+ "jupyter",
13
+ "kernel",
14
+ "typescript",
15
+ "notebook",
16
+ "deno",
17
+ "node",
18
+ "cdp",
19
+ "devtools-protocol"
20
+ ],
12
21
  "type": "module",
13
22
  "engines": {
14
23
  "node": ">=24"
@@ -20,6 +29,14 @@
20
29
  ".": {
21
30
  "types": "./dist/mod.d.ts",
22
31
  "default": "./dist/mod.js"
32
+ },
33
+ "./globals": {
34
+ "types": "./dist/globals.d.ts",
35
+ "default": "./dist/globals.js"
36
+ },
37
+ "./globals/deno-compat": {
38
+ "types": "./dist/globals-deno-compat.d.ts",
39
+ "default": "./dist/globals-deno-compat.js"
23
40
  }
24
41
  },
25
42
  "files": [
@@ -36,9 +53,13 @@
36
53
  "install:node": "node src/main.ts install --runtime node",
37
54
  "install:deno": "node src/main.ts install --runtime deno",
38
55
  "install:node-deno": "node src/main.ts install --runtime node --deno-compat",
56
+ "install:bun": "node src/main.ts install --runtime bun",
57
+ "install:workerd": "node src/main.ts install --runtime workerd",
58
+ "install:celld": "node src/main.ts install --runtime celld",
39
59
  "prepack": "npm run build",
40
60
  "test": "node --test test/*.test.ts",
41
- "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py"
61
+ "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
62
+ "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
42
63
  },
43
64
  "dependencies": {
44
65
  "acorn": "^8.15.0",
@@ -47,6 +68,8 @@
47
68
  },
48
69
  "devDependencies": {
49
70
  "@types/node": "^24.19.2",
50
- "typescript": "^5.9.3"
71
+ "typescript": "^5.9.3",
72
+ "workerd": "1.20261015.1",
73
+ "wrangler": "4.151.0"
51
74
  }
52
75
  }
@@ -4,17 +4,25 @@
4
4
  // then drives it over CDP. Everything the host needs from inside the
5
5
  // runtime lives on globalThis.__cdpk; output to the host goes through the
6
6
  // `__cdpk_send` CDP binding (Runtime.addBinding) plus plain stdout/stderr.
7
+ // Bun is launched as `bun runtime/bootstrap.mjs` and driven over a pipe
8
+ // instead; see bun-channel.mjs.
7
9
  //
8
- // Plain JavaScript on purpose: the same file must load unmodified in Node
9
- // and in Deno.
10
+ // Plain JavaScript on purpose: the same file must load unmodified in Node,
11
+ // Deno and Bun.
10
12
 
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
14
  import { createJupyter } from "./display.mjs";
12
15
 
13
16
  const isDeno = typeof globalThis.Deno?.version?.deno === "string";
17
+ const isBun = typeof globalThis.Bun?.version === "string";
14
18
  const ENC = new TextEncoder();
15
19
 
16
20
  const cwd = isDeno ? Deno.cwd() : process.cwd();
17
- const cwdUrl = new URL(`file://${cwd.endsWith("/") ? cwd : cwd + "/"}`);
21
+ // pathToFileURL, not string concatenation: a directory name containing
22
+ // "#", "?" or "%" would otherwise cut the path short or turn into a query,
23
+ // breaking relative imports and import.meta (#22). It also handles Windows
24
+ // drive letters.
25
+ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
18
26
  // Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
19
27
  const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
20
28
 
@@ -72,27 +80,82 @@ function resolveRelative(specifier) {
72
80
  return /^\.{0,2}\//.test(specifier) ? new URL(specifier, replUrl).href : specifier;
73
81
  }
74
82
 
75
- if (!isDeno) {
83
+ if (!isDeno && !isBun) {
76
84
  // Deno resolves npm:/jsr: natively; Node gets a resolve hook that adds
77
85
  // them and re-parents cell imports to the notebook's directory.
78
86
  const { installNodeResolveHooks } = await import("./node-hooks.mjs");
79
87
  installNodeResolveHooks({ shimUrl: import.meta.url, replUrl: replUrl.href });
80
88
  }
81
89
 
90
+ // In a Deno project (#13), Deno's own resolution (import map, npm:, jsr:)
91
+ // goes first, as in `deno jupyter`. A bare package it can't place, because
92
+ // package.json dependencies resolve relative to *this* module rather than
93
+ // the notebook, is retried from the project's node_modules.
94
+ const inDenoProject = isDeno && Deno.env.get("CDPK_DENO_PROJECT") === "1";
95
+ const { resolveFromNodeModules } = inDenoProject ? await import("./node-modules-resolve.mjs") : {};
96
+
97
+ function isResolutionError(err, specifier) {
98
+ return err instanceof TypeError && err.message.includes(`"${specifier}"`);
99
+ }
100
+
101
+ async function denoImport(specifier, options) {
102
+ const resolved = resolveRelative(specifier);
103
+ try {
104
+ return await import(resolved, options);
105
+ } catch (err) {
106
+ const fromNodeModules = resolveFromNodeModules && isResolutionError(err, specifier)
107
+ ? resolveFromNodeModules(specifier, cwd)
108
+ : null;
109
+ if (fromNodeModules === null) throw err;
110
+ return await import(fromNodeModules, options);
111
+ }
112
+ }
113
+
114
+ // Bun has neither npm: nor jsr:, and would resolve a bare name from this
115
+ // file's directory. npm:/jsr: install into a per-user cache as on Node
116
+ // (with `bun add`); anything else bare resolves from the notebook's
117
+ // directory, so its node_modules, or Bun's auto-install, supplies it.
118
+ const packages = isBun ? await import("./node-hooks.mjs") : null;
119
+
120
+ function bunResolve(specifier) {
121
+ let pkg = null;
122
+ if (specifier.startsWith("npm:")) pkg = packages.parsePackageSpecifier(specifier.slice(4));
123
+ else if (specifier.startsWith("jsr:")) pkg = packages.jsrToNpm(specifier.slice(4));
124
+ if (pkg) {
125
+ const { dir, alias } = packages.ensureInstalled(pkg, "bun");
126
+ return Bun.resolveSync(alias + pkg.subpath, dir);
127
+ }
128
+ // Relative, absolute, or a URL scheme such as node:, bun: or file:.
129
+ if (/^(?:\.{0,2}\/|[a-z][a-z0-9+.-]*:)/i.test(specifier)) return resolveRelative(specifier);
130
+ return Bun.resolveSync(specifier, cwd);
131
+ }
132
+
82
133
  globalThis.__cdpk = {
83
134
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
84
135
  tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
85
136
  // Node: the resolve hook re-parents these imports. Deno would resolve
86
- // relative paths against this file, so do it here.
87
- import: (specifier, options) => import(isDeno ? resolveRelative(specifier) : specifier, options),
137
+ // relative paths against this file, so denoImport does it here.
138
+ import: (specifier, options) =>
139
+ isDeno
140
+ ? denoImport(specifier, options)
141
+ : isBun
142
+ ? import(bunResolve(specifier), options)
143
+ : import(specifier, options),
88
144
  meta: {
89
145
  url: replUrl.href,
90
146
  dirname: cwd,
91
- filename: replUrl.pathname,
147
+ filename: fileURLToPath(replUrl),
92
148
  resolve: (specifier) => new URL(specifier, replUrl).href,
93
149
  },
94
150
  barrier,
95
151
  broadcastResult: jupyter.broadcastResult,
152
+ // Frontend comm messages (#9), from the host's shell channel. Handlers
153
+ // run after one macrotask, like cells, so interrupt can reach them.
154
+ async commDispatch(msgType, content, metadata, buffers64) {
155
+ await new Promise((resolve) => setTimeout(resolve, 0));
156
+ const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
157
+ return await jupyter.commDispatch(msgType, content, metadata, buffers);
158
+ },
96
159
  describeError(value) {
97
160
  if (value instanceof Error) {
98
161
  return JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? "" });
@@ -146,6 +209,14 @@ if (isDeno) {
146
209
  // Keep the event loop alive between cells.
147
210
  setInterval(() => {}, 1 << 30);
148
211
 
212
+ if (isBun) {
213
+ const { startChannel } = await import("./bun-channel.mjs");
214
+ const { createHandler } = await import("./evaluator.mjs");
215
+ startChannel(createHandler(globalThis.__cdpk), {
216
+ versions: { runtime: Bun.version, engine: `JavaScriptCore (WebKit ${process.versions.webkit.slice(0, 12)})` },
217
+ });
218
+ }
219
+
149
220
  async function platform() {
150
221
  if (isDeno) {
151
222
  const stdinBuf = new Uint8Array(1);
@@ -0,0 +1,3 @@
1
+ import type { Handler } from "./evaluator.mjs";
2
+
3
+ export function startChannel(handle: Handler, ready: { [key: string]: unknown }, fd?: number): void;
@@ -0,0 +1,58 @@
1
+ // The Bun target's side of the host connection (#7), in place of CDP.
2
+ //
3
+ // Bun's --inspect speaks the WebKit Inspector Protocol, which has no
4
+ // replMode, Runtime.addBinding or Runtime.terminateExecution, so the host
5
+ // doesn't drive Bun over an inspector. It sends requests as JSON lines over
6
+ // fd 3, a pipe only the host holds (there is no port to discover), and the
7
+ // cells are evaluated here, in-process, by evaluator.mjs. Replies, and the
8
+ // iopub messages that the CDP binding carries for other targets, go back the
9
+ // same way. stdout and stderr stay ordinary pipes, ordered against those
10
+ // messages by bootstrap.mjs's barrier markers.
11
+
12
+ import fs from "node:fs";
13
+
14
+ const CHANNEL_FD = 3;
15
+
16
+ /** Synchronous, like a CDP binding call, so output order is kept. */
17
+ function writeAll(fd, text) {
18
+ const bytes = Buffer.from(text);
19
+ for (let offset = 0; offset < bytes.length;) {
20
+ try {
21
+ offset += fs.writeSync(fd, bytes, offset);
22
+ } catch (err) {
23
+ // The reader's side of the pipe is full; the fd may be non-blocking.
24
+ if (err.code !== "EAGAIN") throw err;
25
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Serves `handle` (see evaluator.mjs) over fd 3 and installs
32
+ * globalThis.__cdpk_send, the function bootstrap.mjs sends iopub messages
33
+ * through. `ready` is sent first, once the target can take requests.
34
+ */
35
+ export function startChannel(handle, ready, fd = CHANNEL_FD) {
36
+ const send = (message) => writeAll(fd, JSON.stringify(message) + "\n");
37
+ Object.defineProperty(globalThis, "__cdpk_send", {
38
+ value: (payload) => writeAll(fd, payload + "\n"),
39
+ enumerable: false,
40
+ });
41
+
42
+ let buffer = "";
43
+ const input = fs.createReadStream(null, { fd, autoClose: false, encoding: "utf8" });
44
+ input.on("data", (chunk) => {
45
+ buffer += chunk;
46
+ for (let nl = buffer.indexOf("\n"); nl !== -1; nl = buffer.indexOf("\n")) {
47
+ const request = JSON.parse(buffer.slice(0, nl));
48
+ buffer = buffer.slice(nl + 1);
49
+ handle(request).then(
50
+ (reply) => send({ kind: "reply", id: request.id, ...reply }),
51
+ (err) => send({ kind: "reply", id: request.id, failure: String(err?.stack ?? err) }),
52
+ );
53
+ }
54
+ });
55
+ // The host is gone; don't linger as an orphan.
56
+ input.on("end", () => process.exit(0));
57
+ send({ kind: "ready", ...ready });
58
+ }
@@ -0,0 +1,46 @@
1
+ /** A comm message as a handler receives it. */
2
+ export interface CommMessage {
3
+ data: { [key: string]: unknown };
4
+ metadata: { [key: string]: unknown };
5
+ buffers: Uint8Array[];
6
+ }
7
+
8
+ export interface CommSendOptions {
9
+ metadata?: { [key: string]: unknown };
10
+ buffers?: Uint8Array[];
11
+ }
12
+
13
+ /** One end of a Jupyter comm channel. */
14
+ export interface Comm {
15
+ readonly id: string;
16
+ readonly targetName: string;
17
+ readonly closed: boolean;
18
+ send(data?: { [key: string]: unknown }, options?: CommSendOptions): void;
19
+ close(data?: { [key: string]: unknown }, options?: CommSendOptions): void;
20
+ onMsg(handler: (msg: CommMessage) => void | Promise<void>): void;
21
+ onClose(handler: (msg: CommMessage) => void | Promise<void>): void;
22
+ }
23
+
24
+ /** `jupyter.comm`: open comms, and accept comms the frontend opens. */
25
+ export interface CommApi {
26
+ open(targetName: string, data?: { [key: string]: unknown }, options?: CommSendOptions): Comm;
27
+ registerTarget(targetName: string, handler: (comm: Comm, msg: CommMessage) => void | Promise<void>): void;
28
+ unregisterTarget(targetName: string): void;
29
+ }
30
+
31
+ export interface Comms {
32
+ api: CommApi;
33
+ /** Routes a frontend comm message; resolves to whether a comm or target took it. */
34
+ dispatch(
35
+ msgType: string,
36
+ content: { [key: string]: unknown },
37
+ metadata: { [key: string]: unknown },
38
+ buffers: Uint8Array[],
39
+ ): Promise<boolean>;
40
+ list(): { commId: string; targetName: string }[];
41
+ }
42
+
43
+ export function createComms(
44
+ send: (msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void,
45
+ newId?: () => string,
46
+ ): Comms;
@@ -0,0 +1,108 @@
1
+ // @ts-self-types="./comm.d.mts"
2
+ // Jupyter comms: bidirectional channels between kernel and frontend, the
3
+ // transport ipywidgets and anywidget are built on (#9). Runs inside the
4
+ // target runtime.
5
+ //
6
+ // Kernel-to-frontend messages (comm_open, comm_msg, comm_close) go out
7
+ // through `send`, the same ordered path as display messages. Frontend
8
+ // messages arrive from the host through `dispatch`, which returns whether
9
+ // anything in the kernel handled them, so the host can answer an unknown
10
+ // target's comm_open with a comm_close, as the protocol asks.
11
+
12
+ class Comm {
13
+ #send;
14
+ #onClosed;
15
+ #msgHandlers = [];
16
+ #closeHandlers = [];
17
+ closed = false;
18
+
19
+ constructor(id, targetName, send, onClosed) {
20
+ this.id = id;
21
+ this.targetName = targetName;
22
+ this.#send = send;
23
+ this.#onClosed = onClosed;
24
+ }
25
+
26
+ send(data = {}, { metadata = {}, buffers = [] } = {}) {
27
+ if (this.closed) return;
28
+ this.#send("comm_msg", { comm_id: this.id, data }, metadata, buffers);
29
+ }
30
+
31
+ close(data = {}, { metadata = {}, buffers = [] } = {}) {
32
+ if (this.closed) return;
33
+ this.#send("comm_close", { comm_id: this.id, data }, metadata, buffers);
34
+ // close() is synchronous for callers; its close handlers run detached.
35
+ void this._closed({ data, metadata, buffers });
36
+ }
37
+
38
+ onMsg(handler) {
39
+ this.#msgHandlers.push(handler);
40
+ }
41
+
42
+ onClose(handler) {
43
+ this.#closeHandlers.push(handler);
44
+ }
45
+
46
+ async _receive(msg) {
47
+ for (const handler of this.#msgHandlers) await handler(msg);
48
+ }
49
+
50
+ async _closed(msg) {
51
+ if (this.closed) return;
52
+ this.closed = true;
53
+ this.#onClosed(this.id);
54
+ for (const handler of this.#closeHandlers) await handler(msg);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {(msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void} send
60
+ * @param {() => string} [newId]
61
+ */
62
+ export function createComms(send, newId = () => crypto.randomUUID()) {
63
+ const comms = new Map(); // comm_id -> Comm
64
+ const targets = new Map(); // target_name -> handler
65
+ const forget = (id) => comms.delete(id);
66
+
67
+ function track(id, targetName) {
68
+ const comm = new Comm(id, targetName, send, forget);
69
+ comms.set(id, comm);
70
+ return comm;
71
+ }
72
+
73
+ const api = {
74
+ open(targetName, data = {}, { metadata = {}, buffers = [] } = {}) {
75
+ const comm = track(newId(), targetName);
76
+ send("comm_open", { comm_id: comm.id, target_name: targetName, data }, metadata, buffers);
77
+ return comm;
78
+ },
79
+ registerTarget(targetName, handler) {
80
+ targets.set(targetName, handler);
81
+ },
82
+ unregisterTarget(targetName) {
83
+ targets.delete(targetName);
84
+ },
85
+ };
86
+
87
+ async function dispatch(msgType, content, metadata, buffers) {
88
+ const msg = { data: content.data ?? {}, metadata: metadata ?? {}, buffers: buffers ?? [] };
89
+ if (msgType === "comm_open") {
90
+ const handler = targets.get(content.target_name);
91
+ if (!handler) return false;
92
+ await handler(track(content.comm_id, content.target_name), msg);
93
+ return true;
94
+ }
95
+ const comm = comms.get(content.comm_id);
96
+ if (!comm) return false;
97
+ if (msgType === "comm_msg") await comm._receive(msg);
98
+ else if (msgType === "comm_close") await comm._closed(msg);
99
+ else return false;
100
+ return true;
101
+ }
102
+
103
+ function list() {
104
+ return [...comms.values()].map((comm) => ({ commId: comm.id, targetName: comm.targetName }));
105
+ }
106
+
107
+ return { api, dispatch, list };
108
+ }
@@ -1,3 +1,4 @@
1
+ // @ts-self-types="./deno-compat.d.mts"
1
2
  // An opt-in `Deno` namespace for Node targets, so notebooks written for
2
3
  // `deno jupyter` keep working: Deno.jupyter plus the file, env and process
3
4
  // APIs notebooks commonly reach for, implemented on Node's.
@@ -1,3 +1,8 @@
1
+ import type { CommApi, Comms } from "./comm.d.mts";
2
+ import type { Widget, WidgetOptions } from "./widget.d.mts";
3
+
4
+ export type { CommApi, Widget, WidgetOptions };
5
+
1
6
  export const $display: unique symbol;
2
7
 
3
8
  export type MediaBundle = { [mediaType: string]: unknown };
@@ -33,6 +38,10 @@ export interface JupyterNamespace {
33
38
  svg(strings: TemplateStringsArray, ...values: unknown[]): Displayable;
34
39
  image(obj: string | Uint8Array): Displayable;
35
40
  $display: typeof $display;
41
+ /** Jupyter comms: bidirectional channels to the frontend. */
42
+ comm: CommApi;
43
+ /** An anywidget-compatible widget; display the result to show it. */
44
+ widget(options: WidgetOptions): Widget;
36
45
  }
37
46
 
38
47
  export interface Jupyter {
@@ -40,6 +49,8 @@ export interface Jupyter {
40
49
  broadcastResult(executionCount: number, result: unknown): Promise<void>;
41
50
  confirm(message?: string): boolean;
42
51
  prompt(message?: string, defaultValue?: string, options?: { password?: boolean }): string | null;
52
+ /** Routes a frontend comm message into the kernel's comms. */
53
+ commDispatch: Comms["dispatch"];
43
54
  }
44
55
 
45
56
  export function escapeHTML(str: string): string;