@tlockney/cdp-kernel 0.1.1 → 0.2.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.
package/dist/package.js CHANGED
@@ -8,14 +8,22 @@
8
8
  // import its bootstrap from an https URL, so a JSR install copies those
9
9
  // files into a local cache first.
10
10
  import { createHash } from "node:crypto";
11
- import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join } from "node:path";
13
+ import { join, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
17
- export const VERSION = "0.1.1";
18
- export const RUNTIME_FILES = ["bootstrap.mjs", "display.mjs", "node-hooks.mjs", "deno-compat.mjs"];
17
+ export const VERSION = "0.2.0";
18
+ export const RUNTIME_FILES = [
19
+ "bootstrap.mjs",
20
+ "display.mjs",
21
+ "comm.mjs",
22
+ "widget.mjs",
23
+ "node-hooks.mjs",
24
+ "deno-compat.mjs",
25
+ "node-modules-resolve.mjs",
26
+ ];
19
27
  export function hostRuntime() {
20
28
  return process.versions.deno === undefined ? "node" : "deno";
21
29
  }
@@ -57,15 +65,29 @@ async function fetchText(url) {
57
65
  throw new Error(`fetching ${url}: ${res.status} ${res.statusText}`);
58
66
  return await res.text();
59
67
  }
68
+ /** True when a file: URL points inside a node_modules directory (an npm install). */
69
+ export function isInsideNodeModules(url) {
70
+ return url.startsWith("file:") && fileURLToPath(url).split(sep).includes("node_modules");
71
+ }
72
+ function readLocal(url) {
73
+ return Promise.resolve(readFileSync(fileURLToPath(url), "utf8"));
74
+ }
60
75
  /**
61
76
  * A local directory holding runtime/*.mjs. `baseUrl` is the runtime/
62
77
  * directory's URL relative to the calling module. A file: URL is used in
63
- * place; anything else is copied once into a cache keyed by version and
64
- * URL.
78
+ * place unless `copy` is set; anything else is copied once into a cache
79
+ * keyed by version and URL.
80
+ *
81
+ * `copy` exists for Deno targets of an npm install: Deno resolves imports
82
+ * made by a module under node_modules as npm-package imports, without the
83
+ * notebook project's import map, so the bootstrap must load from outside it.
65
84
  */
66
- export async function runtimeDir(baseUrl, cache = cacheRoot(), get = fetchText) {
67
- if (baseUrl.startsWith("file:"))
85
+ export async function runtimeDir(baseUrl, cache = cacheRoot(), get = fetchText, { copy = false } = {}) {
86
+ const local = baseUrl.startsWith("file:");
87
+ if (local && !copy)
68
88
  return fileURLToPath(baseUrl);
89
+ if (local)
90
+ get = readLocal;
69
91
  const key = createHash("sha256").update(baseUrl).digest("hex").slice(0, 12);
70
92
  const dir = join(cache, "runtime", `${VERSION}-${key}`);
71
93
  if (RUNTIME_FILES.every((name) => existsSync(join(dir, name))))
package/dist/target.d.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
2
  export type RuntimeName = "node" | "deno";
3
+ export interface LaunchOptions {
4
+ /** Runtime binary; defaults to `deno`, or `node` (this one when hosted by Node). */
5
+ executable?: string;
6
+ /** A Deno target's project config (deno.json), passed as --config. */
7
+ denoConfig?: string;
8
+ }
3
9
  /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
4
- export declare function launchCommand(runtime: RuntimeName, bootstrap: string, executable?: string): {
10
+ export declare function launchCommand(runtime: RuntimeName, bootstrap: string, { executable, denoConfig }?: LaunchOptions): {
5
11
  cmd: string;
6
12
  args: string[];
7
13
  };
@@ -56,6 +62,7 @@ export declare class Target {
56
62
  cwd?: string;
57
63
  executable?: string;
58
64
  denoCompat?: boolean;
65
+ denoConfig?: string;
59
66
  }): Promise<Target>;
60
67
  /** Resolves once all output and messages sent so far have been delivered. */
61
68
  flush(): Promise<void>;
@@ -72,6 +79,20 @@ export declare class Target {
72
79
  callFunctionOn(functionDeclaration: string, args: CdpParams[]): Promise<CdpParams>;
73
80
  /** The broadcastResult step of Deno's execute flow (jupyter/mod.rs). */
74
81
  broadcastResult(executionCount: number, result: RemoteObject): Promise<void>;
82
+ /**
83
+ * Hands a frontend comm message to the target's comms. Resolves to
84
+ * whether a comm or registered target took it, or to the error a handler
85
+ * threw. Rejects with Interrupted after interrupt().
86
+ */
87
+ dispatchComm(msgType: string, content: CdpParams, metadata: CdpParams, buffers: Uint8Array[]): Promise<{
88
+ handled: boolean;
89
+ } | {
90
+ error: {
91
+ name: string;
92
+ message: string;
93
+ stack: string;
94
+ };
95
+ }>;
75
96
  describeError(exception: RemoteObject): Promise<{
76
97
  name: string;
77
98
  message: string;
package/dist/target.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // A target runtime driven over CDP: the stand-in for Deno's REPL isolate and
2
2
  // the ops in cli/ops/jupyter.rs. Any runtime whose --inspect speaks CDP and
3
3
  // can load runtime/bootstrap.mjs works; Node and Deno are wired up here.
4
+ import { Buffer } from "node:buffer";
4
5
  import process from "node:process";
5
6
  import { spawn } from "node:child_process";
6
7
  import { join } from "node:path";
7
8
  import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
8
9
  import { SHIM } from "./transform.js";
9
- import { hostRuntime, runtimeDir } from "./package.js";
10
+ import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
10
11
  const BINDING = "__cdpk_send";
11
12
  const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
12
13
  const MAX_MARKER_LEN = 24;
@@ -15,8 +16,12 @@ const MAX_MARKER_LEN = 24;
15
16
  // sends to the server log rather than the notebook).
16
17
  const INSPECTOR_CHATTER = /^(?:\x1b\[[0-9;]*m)*(Debugger listening on |Debugger attached\.|For help, see: |Visit chrome:\/\/inspect|Waiting for the debugger to disconnect|Download(?:\x1b\[[0-9;]*m)* https?:\/\/).*\n/gm;
17
18
  /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
18
- export function launchCommand(runtime, bootstrap, executable) {
19
- const inspect = "--inspect=127.0.0.1:0";
19
+ export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {}) {
20
+ // The CDP WebSocket has no authentication, so its URL must stay private:
21
+ // by default the inspector lists it at /json/list for any local process
22
+ // to read. Publishing it only on stderr, which only this host reads,
23
+ // leaves a random id nobody else can learn (#22).
24
+ const inspect = ["--inspect=127.0.0.1:0", "--inspect-publish-uid=stderr"];
20
25
  if (runtime === "deno") {
21
26
  // Deno discovers config from the main module's location, which would
22
27
  // put npm: imports in BYONM mode against this package's own
@@ -25,12 +30,16 @@ export function launchCommand(runtime, bootstrap, executable) {
25
30
  // into this package.
26
31
  return {
27
32
  cmd: executable ?? "deno",
28
- args: ["run", "--allow-all", "--node-modules-dir=none", "--no-lock", inspect, bootstrap],
33
+ // In a project, its config (import map, nodeModulesDir, ...) applies
34
+ // instead; --no-lock still keeps the kernel out of the project's lock.
35
+ args: denoConfig === undefined
36
+ ? ["run", "--allow-all", "--node-modules-dir=none", "--no-lock", ...inspect, bootstrap]
37
+ : ["run", "--allow-all", "--config", denoConfig, "--no-lock", ...inspect, bootstrap],
29
38
  };
30
39
  }
31
40
  // Under a Deno host, process.execPath is deno; find node on PATH instead.
32
41
  const node = hostRuntime() === "node" ? process.execPath : "node";
33
- return { cmd: executable ?? node, args: [inspect, bootstrap] };
42
+ return { cmd: executable ?? node, args: [...inspect, bootstrap] };
34
43
  }
35
44
  function isTargetMessage(value) {
36
45
  if (typeof value !== "object" || value === null)
@@ -182,11 +191,20 @@ export class Target {
182
191
  this.versions = versions;
183
192
  }
184
193
  static async launch(runtime, events, options = {}) {
185
- const bootstrap = join(await runtimeDir(new URL("../runtime/", import.meta.url).href), "bootstrap.mjs");
186
- const { cmd, args } = launchCommand(runtime, bootstrap, options.executable);
194
+ const runtimeUrl = new URL("../runtime/", import.meta.url).href;
195
+ // A Deno target ignores the project's import map for imports made from
196
+ // under node_modules, so an npm install's runtime files are copied out.
197
+ const copy = runtime === "deno" && isInsideNodeModules(runtimeUrl);
198
+ const bootstrap = join(await runtimeDir(runtimeUrl, undefined, undefined, { copy }), "bootstrap.mjs");
199
+ const { cmd, args } = launchCommand(runtime, bootstrap, {
200
+ executable: options.executable,
201
+ denoConfig: options.denoConfig,
202
+ });
187
203
  const env = { ...process.env, FORCE_COLOR: "1" };
188
204
  if (options.denoCompat)
189
205
  env.CDPK_DENO_COMPAT = "1";
206
+ if (runtime === "deno" && options.denoConfig !== undefined)
207
+ env.CDPK_DENO_PROJECT = "1";
190
208
  delete env.NO_COLOR;
191
209
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
192
210
  child.stdout.setEncoding("utf8");
@@ -320,6 +338,42 @@ export class Target {
320
338
  arg,
321
339
  ]);
322
340
  }
341
+ /**
342
+ * Hands a frontend comm message to the target's comms. Resolves to
343
+ * whether a comm or registered target took it, or to the error a handler
344
+ * threw. Rejects with Interrupted after interrupt().
345
+ */
346
+ async dispatchComm(msgType, content, metadata, buffers) {
347
+ this.#interrupted = false;
348
+ let res;
349
+ try {
350
+ res = await this.#shell.send("Runtime.callFunctionOn", {
351
+ functionDeclaration: `function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`,
352
+ arguments: [
353
+ { value: msgType },
354
+ { value: content },
355
+ { value: metadata },
356
+ { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
357
+ ],
358
+ executionContextId: this.#contextId,
359
+ awaitPromise: true,
360
+ returnByValue: true,
361
+ });
362
+ }
363
+ catch (err) {
364
+ if (err instanceof CdpAbandoned || this.#interrupted)
365
+ throw new Interrupted("Comm handler interrupted");
366
+ throw err;
367
+ }
368
+ const details = res.exceptionDetails;
369
+ if (details) {
370
+ const error = details.exception
371
+ ? await this.describeError(details.exception)
372
+ : { name: "Error", message: details.text, stack: "" };
373
+ return { error };
374
+ }
375
+ return { handled: res.result?.value === true };
376
+ }
323
377
  async describeError(exception) {
324
378
  const arg = exception.objectId ? { objectId: exception.objectId } : { value: exception.value };
325
379
  const res = await this.callFunctionOn(`function (e) { return globalThis.${SHIM}.describeError(e); }`, [arg]);
@@ -2,9 +2,27 @@ export declare const SHIM = "__cdpk";
2
2
  export declare class CellSyntaxError extends Error {
3
3
  name: string;
4
4
  }
5
+ export type JsxOptions = {
6
+ mode: "automatic";
7
+ importSource?: string;
8
+ development: boolean;
9
+ } | {
10
+ mode: "classic";
11
+ factory?: string;
12
+ fragment?: string;
13
+ };
14
+ /**
15
+ * Compiler options for cells, e.g. from a Deno project's compilerOptions
16
+ * (see src/deno-config.ts). Without any, JSX compiles to classic
17
+ * React.createElement calls, Deno's own default.
18
+ */
19
+ export interface CellTransformOptions {
20
+ jsx?: JsxOptions;
21
+ experimentalDecorators?: boolean;
22
+ }
5
23
  /** Rewrites module-only syntax in plain JavaScript into script syntax. */
6
24
  export declare function rewriteModuleSyntax(js: string): string;
7
- export declare function transformCell(code: string): string;
25
+ export declare function transformCell(code: string, options?: CellTransformOptions): string;
8
26
  /**
9
27
  * Deno treats a cell like `{ "a": 1 }` as an object literal rather than a
10
28
  * block, as browser consoles do. Returns the wrapped form to try first, or
package/dist/transform.js CHANGED
@@ -27,7 +27,17 @@ const PRELUDE = `'use strict'; await ${SHIM}.tick();\n`;
27
27
  export class CellSyntaxError extends Error {
28
28
  name = "SyntaxError";
29
29
  }
30
- function stripTypes(code) {
30
+ function jsxSettings(jsx) {
31
+ if (jsx?.mode === "automatic") {
32
+ return { jsx: "automatic", jsxImportSource: jsx.importSource, jsxDev: jsx.development };
33
+ }
34
+ return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
35
+ }
36
+ function stripTypes(code, options) {
37
+ const compilerOptions = {
38
+ verbatimModuleSyntax: true,
39
+ ...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
40
+ };
31
41
  // Like Deno: prefer TSX, fall back to TS for `<T>x` assertions.
32
42
  let firstError;
33
43
  for (const loader of ["tsx", "ts"]) {
@@ -35,8 +45,8 @@ function stripTypes(code) {
35
45
  return transformSync(code, {
36
46
  loader,
37
47
  target: "esnext",
38
- jsx: "transform",
39
- tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
48
+ ...jsxSettings(options.jsx),
49
+ tsconfigRaw: { compilerOptions },
40
50
  logLevel: "silent",
41
51
  }).code;
42
52
  }
@@ -132,8 +142,8 @@ export function rewriteModuleSyntax(js) {
132
142
  out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
133
143
  return out;
134
144
  }
135
- export function transformCell(code) {
136
- return PRELUDE + rewriteModuleSyntax(stripTypes(code));
145
+ export function transformCell(code, options = {}) {
146
+ return PRELUDE + rewriteModuleSyntax(stripTypes(code, options));
137
147
  }
138
148
  /**
139
149
  * Deno treats a cell like `{ "a": 1 }` as an object literal rather than a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.1.1",
3
+ "version": "0.2.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>",
@@ -20,6 +20,14 @@
20
20
  ".": {
21
21
  "types": "./dist/mod.d.ts",
22
22
  "default": "./dist/mod.js"
23
+ },
24
+ "./globals": {
25
+ "types": "./dist/globals.d.ts",
26
+ "default": "./dist/globals.js"
27
+ },
28
+ "./globals/deno-compat": {
29
+ "types": "./dist/globals-deno-compat.d.ts",
30
+ "default": "./dist/globals-deno-compat.js"
23
31
  }
24
32
  },
25
33
  "files": [
@@ -38,7 +46,8 @@
38
46
  "install:node-deno": "node src/main.ts install --runtime node --deno-compat",
39
47
  "prepack": "npm run build",
40
48
  "test": "node --test test/*.test.ts",
41
- "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py"
49
+ "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
50
+ "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
42
51
  },
43
52
  "dependencies": {
44
53
  "acorn": "^8.15.0",
@@ -8,13 +8,18 @@
8
8
  // Plain JavaScript on purpose: the same file must load unmodified in Node
9
9
  // and in Deno.
10
10
 
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
12
  import { createJupyter } from "./display.mjs";
12
13
 
13
14
  const isDeno = typeof globalThis.Deno?.version?.deno === "string";
14
15
  const ENC = new TextEncoder();
15
16
 
16
17
  const cwd = isDeno ? Deno.cwd() : process.cwd();
17
- const cwdUrl = new URL(`file://${cwd.endsWith("/") ? cwd : cwd + "/"}`);
18
+ // pathToFileURL, not string concatenation: a directory name containing
19
+ // "#", "?" or "%" would otherwise cut the path short or turn into a query,
20
+ // breaking relative imports and import.meta (#22). It also handles Windows
21
+ // drive letters.
22
+ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
18
23
  // Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
19
24
  const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
20
25
 
@@ -79,20 +84,51 @@ if (!isDeno) {
79
84
  installNodeResolveHooks({ shimUrl: import.meta.url, replUrl: replUrl.href });
80
85
  }
81
86
 
87
+ // In a Deno project (#13), Deno's own resolution (import map, npm:, jsr:)
88
+ // goes first, as in `deno jupyter`. A bare package it can't place, because
89
+ // package.json dependencies resolve relative to *this* module rather than
90
+ // the notebook, is retried from the project's node_modules.
91
+ const inDenoProject = isDeno && Deno.env.get("CDPK_DENO_PROJECT") === "1";
92
+ const { resolveFromNodeModules } = inDenoProject ? await import("./node-modules-resolve.mjs") : {};
93
+
94
+ function isResolutionError(err, specifier) {
95
+ return err instanceof TypeError && err.message.includes(`"${specifier}"`);
96
+ }
97
+
98
+ async function denoImport(specifier, options) {
99
+ const resolved = resolveRelative(specifier);
100
+ try {
101
+ return await import(resolved, options);
102
+ } catch (err) {
103
+ const fromNodeModules = resolveFromNodeModules && isResolutionError(err, specifier)
104
+ ? resolveFromNodeModules(specifier, cwd)
105
+ : null;
106
+ if (fromNodeModules === null) throw err;
107
+ return await import(fromNodeModules, options);
108
+ }
109
+ }
110
+
82
111
  globalThis.__cdpk = {
83
112
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
84
113
  tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
85
114
  // 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),
115
+ // relative paths against this file, so denoImport does it here.
116
+ import: (specifier, options) => (isDeno ? denoImport(specifier, options) : import(specifier, options)),
88
117
  meta: {
89
118
  url: replUrl.href,
90
119
  dirname: cwd,
91
- filename: replUrl.pathname,
120
+ filename: fileURLToPath(replUrl),
92
121
  resolve: (specifier) => new URL(specifier, replUrl).href,
93
122
  },
94
123
  barrier,
95
124
  broadcastResult: jupyter.broadcastResult,
125
+ // Frontend comm messages (#9), from the host's shell channel. Handlers
126
+ // run after one macrotask, like cells, so interrupt can reach them.
127
+ async commDispatch(msgType, content, metadata, buffers64) {
128
+ await new Promise((resolve) => setTimeout(resolve, 0));
129
+ const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
130
+ return await jupyter.commDispatch(msgType, content, metadata, buffers);
131
+ },
96
132
  describeError(value) {
97
133
  if (value instanceof Error) {
98
134
  return JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? "" });
@@ -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;
@@ -1,3 +1,4 @@
1
+ // @ts-self-types="./display.d.mts"
1
2
  // The Jupyter display API that Deno exposes as `Deno.jupyter`, made
2
3
  // runtime-neutral. Runs inside the target runtime (Node or Deno).
3
4
  //
@@ -10,7 +11,11 @@
10
11
  // Deno.readFileSync -> host.readFile
11
12
  // op_jupyter_broadcast -> host.send
12
13
  // WebGPU texture/buffer display depends on Deno's wgpu bindings and is
13
- // omitted.
14
+ // omitted. Comms and widgets (jupyter.comm, jupyter.widget) are additions
15
+ // Deno doesn't have; see comm.mjs and widget.mjs.
16
+
17
+ import { createComms } from "./comm.mjs";
18
+ import { createWidgetFactory } from "./widget.mjs";
14
19
 
15
20
  export const $display = Symbol.for("Jupyter.display");
16
21
 
@@ -219,6 +224,8 @@ export function createJupyter(host) {
219
224
  return answer === "" ? defaultValue : answer;
220
225
  }
221
226
 
227
+ const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
228
+
222
229
  return {
223
230
  namespace: {
224
231
  broadcast,
@@ -229,9 +236,12 @@ export function createJupyter(host) {
229
236
  svg: taggedTemplate("image/svg+xml"),
230
237
  image,
231
238
  $display,
239
+ comm: comms.api,
240
+ widget: createWidgetFactory(comms.api, $display),
232
241
  },
233
242
  broadcastResult,
234
243
  confirm,
235
244
  prompt,
245
+ commDispatch: comms.dispatch,
236
246
  };
237
247
  }
@@ -0,0 +1,8 @@
1
+ /** True for "pkg", "@scope/pkg", "pkg/sub"; false for paths, URLs and aliases like "@/x". */
2
+ export function isBareSpecifier(specifier: string): boolean;
3
+
4
+ /**
5
+ * An absolute file URL for a bare package import, resolved from the
6
+ * node_modules at or above `fromDir`, or null when none has the package.
7
+ */
8
+ export function resolveFromNodeModules(specifier: string, fromDir: string): string | null;