@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
@@ -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,18 @@
1
+ /** The parts of globalThis.__cdpk the evaluator calls. */
2
+ export interface EvaluatorShim {
3
+ broadcastResult(executionCount: number, value: unknown): void | Promise<void>;
4
+ describeError(value: unknown): string;
5
+ commDispatch(msgType: string, content: object, metadata: object, buffers64: string[]): Promise<unknown>;
6
+ barrier(): number;
7
+ }
8
+
9
+ /** A request from the host: an `op` plus its parameters. */
10
+ export interface EvaluatorRequest {
11
+ op: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export type Handler = (request: EvaluatorRequest) => Promise<{ [key: string]: unknown }>;
16
+
17
+ export function propertyNames(value: unknown): string[];
18
+ export function createHandler(shim: EvaluatorShim, evaluate?: (code: string) => unknown): Handler;
@@ -0,0 +1,83 @@
1
+ // The in-process evaluator behind the targets that don't use CDP: Bun (#7),
2
+ // over a pipe (bun-channel.mjs), and workerd (#8), inside a request
3
+ // (workerd-worker.mjs). Cells arrive as global code from
4
+ // src/transform.ts's transformCellForEval, whose value is a promise of the
5
+ // boxed result. Requests and replies mirror the CDP calls the kernel makes
6
+ // of the other targets.
7
+ //
8
+ // No imports: this file must load in every target.
9
+
10
+ /** Own and inherited property names, as Runtime.getProperties lists them. */
11
+ export function propertyNames(value) {
12
+ if (value === null || value === undefined) return [];
13
+ const names = new Set();
14
+ for (let o = Object(value); o !== null; o = Object.getPrototypeOf(o)) {
15
+ for (const name of Object.getOwnPropertyNames(o)) names.add(name);
16
+ }
17
+ return [...names];
18
+ }
19
+
20
+ /**
21
+ * Answers the host's requests. `shim` is globalThis.__cdpk; `evaluate` runs
22
+ * global code. Values the host refers to later (a result to display, an
23
+ * error to describe) stay here under a handle, the stand-in for a CDP
24
+ * objectId.
25
+ */
26
+ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
27
+ const handles = new Map();
28
+ let nextHandle = 0;
29
+ const remote = (value) => {
30
+ const objectId = `h${++nextHandle}`;
31
+ handles.set(objectId, value);
32
+ return { type: value === null ? "object" : typeof value, objectId };
33
+ };
34
+ const take = (objectId) => {
35
+ const value = handles.get(objectId);
36
+ handles.delete(objectId);
37
+ return value;
38
+ };
39
+
40
+ return async function handle(request) {
41
+ switch (request.op) {
42
+ case "ping":
43
+ return {};
44
+ case "eval": {
45
+ // The host uses a result's handle before its next cell.
46
+ handles.clear();
47
+ try {
48
+ const box = await evaluate(request.code);
49
+ return { result: box === undefined ? { type: "undefined" } : remote(box[0]) };
50
+ } catch (err) {
51
+ return {
52
+ exceptionDetails: { text: "Uncaught", lineNumber: 0, columnNumber: 0, exception: remote(err) },
53
+ };
54
+ }
55
+ }
56
+ case "broadcast":
57
+ await shim.broadcastResult(request.count, take(request.handle));
58
+ return {};
59
+ case "describe":
60
+ return { value: shim.describeError(take(request.handle)) };
61
+ case "comm":
62
+ try {
63
+ const handled = await shim.commDispatch(request.msgType, request.content, request.metadata, request.buffers);
64
+ return { handled: handled === true };
65
+ } catch (err) {
66
+ return { error: JSON.parse(shim.describeError(err)) };
67
+ }
68
+ case "props": {
69
+ let value;
70
+ try {
71
+ value = evaluate(`(${request.expr})`);
72
+ } catch {
73
+ return { names: [] };
74
+ }
75
+ return { names: propertyNames(value) };
76
+ }
77
+ case "barrier":
78
+ return { value: shim.barrier() };
79
+ default:
80
+ throw new Error(`unknown request ${JSON.stringify(request.op)}`);
81
+ }
82
+ };
83
+ }
@@ -7,4 +7,15 @@ export interface PackageSpecifier {
7
7
  export function parsePackageSpecifier(spec: string): PackageSpecifier;
8
8
  export function jsrToNpm(spec: string): PackageSpecifier;
9
9
  export function aliasFor(name: string, range: string): string;
10
+ export type Installer = "npm" | "bun";
11
+ export function installCommand(
12
+ installer: Installer,
13
+ alias: string,
14
+ name: string,
15
+ range: string,
16
+ ): { cmd: string; args: string[] };
17
+ export function ensureInstalled(
18
+ pkg: { name: string; range: string },
19
+ installer?: Installer,
20
+ ): { dir: string; alias: string };
10
21
  export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;
@@ -11,7 +11,9 @@
11
11
  // Uses module.registerHooks (synchronous, in-thread; Node >= 23.5), so the
12
12
  // install can run with spawnSync inside resolve.
13
13
 
14
- import { registerHooks } from "node:module";
14
+ // Not a named import: Bun, which loads this file for its npm:/jsr: helpers,
15
+ // has no registerHooks, and a missing named export fails at link time.
16
+ import module from "node:module";
15
17
  import { spawnSync } from "node:child_process";
16
18
  import { createHash } from "node:crypto";
17
19
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -41,8 +43,12 @@ export function aliasFor(name, range) {
41
43
  return `cdpk-${digest}`;
42
44
  }
43
45
 
44
- function cacheDir() {
45
- const dir = process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm");
46
+ // Each installer keeps its own cache, so npm's and Bun's lockfiles never
47
+ // meet; both read the @jsr registry from .npmrc.
48
+ function cacheDir(installer) {
49
+ const dir = installer === "npm"
50
+ ? process.env.CDPK_CACHE_DIR ?? join(homedir(), ".cache", "cdp-kernel", "npm")
51
+ : join(homedir(), ".cache", "cdp-kernel", installer);
46
52
  if (!existsSync(join(dir, "package.json"))) {
47
53
  mkdirSync(dir, { recursive: true });
48
54
  writeFileSync(join(dir, "package.json"), '{ "private": true }\n');
@@ -51,24 +57,30 @@ function cacheDir() {
51
57
  return dir;
52
58
  }
53
59
 
54
- function ensureInstalled({ name, range }) {
55
- const dir = cacheDir();
60
+ /** The command that installs name@range under `alias` with `installer`. */
61
+ export function installCommand(installer, alias, name, range) {
62
+ const spec = `${alias}@npm:${name}@${range}`;
63
+ // The Bun target runs this inside Bun, so it uses its own binary.
64
+ if (installer === "bun") return { cmd: process.execPath, args: ["add", "--silent", spec] };
65
+ return { cmd: "npm", args: ["install", "--no-audit", "--no-fund", "--loglevel=error", spec] };
66
+ }
67
+
68
+ /** Installs a package into the installer's cache unless it's there already. */
69
+ export function ensureInstalled({ name, range }, installer = "npm") {
70
+ const dir = cacheDir(installer);
56
71
  const alias = aliasFor(name, range);
57
72
  if (!existsSync(join(dir, "node_modules", alias, "package.json"))) {
58
- const result = spawnSync(
59
- "npm",
60
- ["install", "--no-audit", "--no-fund", "--loglevel=error", `${alias}@npm:${name}@${range}`],
61
- { cwd: dir, encoding: "utf8" },
62
- );
73
+ const { cmd, args } = installCommand(installer, alias, name, range);
74
+ const result = spawnSync(cmd, args, { cwd: dir, encoding: "utf8" });
63
75
  if (result.status !== 0) {
64
- throw new Error(`npm install ${name}@${range} failed:\n${result.stderr || result.error}`);
76
+ throw new Error(`${installer} install ${name}@${range} failed:\n${result.stderr || result.error}`);
65
77
  }
66
78
  }
67
79
  return { dir, alias };
68
80
  }
69
81
 
70
82
  export function installNodeResolveHooks({ shimUrl, replUrl }) {
71
- registerHooks({
83
+ module.registerHooks({
72
84
  resolve(specifier, context, nextResolve) {
73
85
  let pkg = null;
74
86
  if (specifier.startsWith("npm:")) pkg = parsePackageSpecifier(specifier.slice(4));
@@ -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;
@@ -0,0 +1,106 @@
1
+ // @ts-self-types="./node-modules-resolve.d.mts"
2
+ // Resolves a bare package import against a notebook directory's
3
+ // node_modules, for Deno targets in a Deno project (#13).
4
+ //
5
+ // Deno resolves package.json dependencies relative to the *importing*
6
+ // module. Cell imports are issued from runtime/bootstrap.mjs, outside the
7
+ // project, and code evaluated over the inspector resolves against the
8
+ // notebook directory treated as a file (one level too high), so neither
9
+ // finds the project's node_modules. Resolving the package to an absolute
10
+ // file URL here sidesteps both; the package's own imports then resolve
11
+ // normally from inside node_modules.
12
+ //
13
+ // Implements the parts of Node's package resolution notebooks need:
14
+ // "exports" (string, subpath map, "*" patterns, nested conditions taken in
15
+ // the package's key order), then "module"/"main"/index.js. Returns null when
16
+ // no node_modules has the package, so the caller can fall back to Deno's
17
+ // own resolution (import map, npm:, jsr:, builtins).
18
+
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+
23
+ // The conditions Deno matches for npm packages, plus the common "module".
24
+ const CONDITIONS = new Set(["deno", "node", "import", "module", "default"]);
25
+
26
+ /** True for "pkg", "@scope/pkg", "pkg/sub"; false for paths, URLs, aliases. */
27
+ export function isBareSpecifier(specifier) {
28
+ if (/^[./]/.test(specifier) || specifier.startsWith("#")) return false;
29
+ if (/^[a-z][a-z0-9+.-]*:/i.test(specifier)) return false; // npm:, node:, file:, https:
30
+ if (specifier.startsWith("@")) return /^@[^/]+\/[^/]+/.test(specifier) && !specifier.startsWith("@/");
31
+ return true;
32
+ }
33
+
34
+ function splitPackage(specifier) {
35
+ const parts = specifier.split("/");
36
+ const nameLength = specifier.startsWith("@") ? 2 : 1;
37
+ return { name: parts.slice(0, nameLength).join("/"), subpath: parts.slice(nameLength).join("/") };
38
+ }
39
+
40
+ function findPackageDir(name, fromDir) {
41
+ for (let dir = fromDir; ; dir = dirname(dir)) {
42
+ const candidate = join(dir, "node_modules", name);
43
+ if (existsSync(join(candidate, "package.json"))) return candidate;
44
+ if (dirname(dir) === dir) return null;
45
+ }
46
+ }
47
+
48
+ /** Picks a target from an exports value: a string, or nested conditions. */
49
+ function conditionalTarget(value) {
50
+ if (typeof value === "string") return value;
51
+ if (Array.isArray(value)) {
52
+ for (const item of value) {
53
+ const target = conditionalTarget(item);
54
+ if (target !== null) return target;
55
+ }
56
+ return null;
57
+ }
58
+ if (value !== null && typeof value === "object") {
59
+ for (const [key, nested] of Object.entries(value)) {
60
+ if (!CONDITIONS.has(key)) continue;
61
+ const target = conditionalTarget(nested);
62
+ if (target !== null) return target;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function exportsTarget(exports, subpath) {
69
+ const key = subpath === "" ? "." : `./${subpath}`;
70
+ const isSubpathMap = exports !== null && typeof exports === "object" && !Array.isArray(exports) &&
71
+ Object.keys(exports).some((k) => k.startsWith("."));
72
+ if (!isSubpathMap) return key === "." ? conditionalTarget(exports) : null;
73
+ if (Object.hasOwn(exports, key)) return conditionalTarget(exports[key]);
74
+ for (const [pattern, value] of Object.entries(exports)) {
75
+ const star = pattern.indexOf("*");
76
+ if (star === -1) continue;
77
+ const prefix = pattern.slice(0, star);
78
+ const suffix = pattern.slice(star + 1);
79
+ if (key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length) {
80
+ const match = key.slice(prefix.length, key.length - suffix.length);
81
+ const target = conditionalTarget(value);
82
+ return target === null ? null : target.replaceAll("*", match);
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** An absolute file URL for `specifier`, or null if no node_modules has it. */
89
+ export function resolveFromNodeModules(specifier, fromDir) {
90
+ if (!isBareSpecifier(specifier)) return null;
91
+ const { name, subpath } = splitPackage(specifier);
92
+ const pkgDir = findPackageDir(name, fromDir);
93
+ if (pkgDir === null) return null;
94
+ const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
95
+
96
+ let target;
97
+ if (pkg.exports !== undefined) {
98
+ target = exportsTarget(pkg.exports, subpath);
99
+ if (target === null) return null; // not exported: not importable
100
+ } else if (subpath !== "") {
101
+ target = subpath;
102
+ } else {
103
+ target = pkg.module ?? pkg.main ?? "index.js";
104
+ }
105
+ return pathToFileURL(join(pkgDir, target)).href;
106
+ }
@@ -0,0 +1,40 @@
1
+ import type { CommApi } from "./comm.d.mts";
2
+ import type { $display, MediaBundle } from "./display.d.mts";
3
+
4
+ export const WIDGET_PROTOCOL_VERSION: "2.1.0";
5
+
6
+ export type WidgetState = { [key: string]: unknown };
7
+
8
+ export interface WidgetOptions {
9
+ /** The anywidget ES module: `export default { render({ model, el }) { ... } }`. */
10
+ esm: string;
11
+ /** Optional CSS for the view. */
12
+ css?: string;
13
+ /** Initial synced state. Uint8Array/ArrayBuffer values travel as binary buffers. */
14
+ state?: WidgetState;
15
+ }
16
+
17
+ /** An anywidget model, synced with its frontend views. Display it to show a view. */
18
+ export interface Widget {
19
+ /** The model id (the comm id). */
20
+ readonly id: string;
21
+ readonly closed: boolean;
22
+ get(key: string): unknown;
23
+ /** Sets one key or several, and syncs the change to the frontend. */
24
+ set(key: string, value: unknown): void;
25
+ set(changes: WidgetState): void;
26
+ on(event: `change:${string}`, listener: (value: unknown, previous: unknown) => void | Promise<void>): void;
27
+ on(event: "change", listener: (changes: WidgetState) => void | Promise<void>): void;
28
+ on(event: "msg:custom", listener: (content: unknown, buffers: Uint8Array[]) => void | Promise<void>): void;
29
+ off(event: string, listener: (...args: never[]) => unknown): void;
30
+ /** A custom message to the view's `model.on("msg:custom", ...)`. */
31
+ send(content: unknown, buffers?: Uint8Array[]): void;
32
+ close(): void;
33
+ fullState(): WidgetState;
34
+ /** Displaying a widget shows a view of it (application/vnd.jupyter.widget-view+json). */
35
+ [$display](): MediaBundle;
36
+ }
37
+
38
+ export function removeBuffers(state: unknown): { state: unknown; bufferPaths: (string | number)[][]; buffers: Uint8Array[] };
39
+ export function putBuffers<T>(state: T, bufferPaths?: (string | number)[][], buffers?: Uint8Array[]): T;
40
+ export function createWidgetFactory(commApi: CommApi, $display: symbol): (options: WidgetOptions) => Widget;
@@ -0,0 +1,208 @@
1
+ // @ts-self-types="./widget.d.mts"
2
+ // anywidget-compatible widgets over Jupyter comms (#9): the kernel half of
3
+ // the ipywidgets model protocol (version 2.1.0) for anywidget's AnyModel,
4
+ // whose frontend is the `anywidget` JupyterLab/Notebook/VS Code extension.
5
+ //
6
+ // const counter = jupyter.widget({ esm, state: { count: 0 } });
7
+ // counter // display it
8
+ // counter.get("count"); counter.set("count", 1);
9
+ // counter.on("change:count", (value, old) => ...);
10
+ //
11
+ // State is synced as in ipywidgets: `update` messages carry changed keys,
12
+ // with binary values lifted out into message buffers at `buffer_paths`
13
+ // (ipywidgets' _remove_buffers/_put_buffers).
14
+
15
+ export const WIDGET_PROTOCOL_VERSION = "2.1.0";
16
+ const WIDGET_TARGET = "jupyter.widget";
17
+ const CONTROL_TARGET = "jupyter.widget.control";
18
+ const VIEW_MIME = "application/vnd.jupyter.widget-view+json";
19
+
20
+ // The anywidget model the frontend instantiates. The module version is a
21
+ // semver range for the frontend's registry; anywidget sends "~major.minor.*"
22
+ // of its own version, and "*" accepts whichever release is installed.
23
+ const ANYWIDGET_MODEL = {
24
+ _model_name: "AnyModel",
25
+ _model_module: "anywidget",
26
+ _model_module_version: "*",
27
+ _view_name: "AnyView",
28
+ _view_module: "anywidget",
29
+ _view_module_version: "*",
30
+ _view_count: null,
31
+ };
32
+
33
+ function isBinary(value) {
34
+ return value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
35
+ }
36
+
37
+ function toBytes(value) {
38
+ if (value instanceof Uint8Array) return value;
39
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
40
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
41
+ }
42
+
43
+ /** Copies `state` with binary values replaced by null, collecting them. */
44
+ export function removeBuffers(state) {
45
+ const bufferPaths = [];
46
+ const buffers = [];
47
+ function walk(value, path) {
48
+ if (isBinary(value)) {
49
+ bufferPaths.push(path);
50
+ buffers.push(toBytes(value));
51
+ return null;
52
+ }
53
+ if (Array.isArray(value)) return value.map((item, i) => walk(item, [...path, i]));
54
+ if (value !== null && typeof value === "object") {
55
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v, [...path, k])]));
56
+ }
57
+ return value;
58
+ }
59
+ return { state: walk(state, []), bufferPaths, buffers };
60
+ }
61
+
62
+ /** Puts received buffers back into `state` at `bufferPaths` (in place). */
63
+ export function putBuffers(state, bufferPaths = [], buffers = []) {
64
+ bufferPaths.forEach((path, i) => {
65
+ let obj = state;
66
+ for (const key of path.slice(0, -1)) obj = obj[key];
67
+ obj[path[path.length - 1]] = buffers[i];
68
+ });
69
+ return state;
70
+ }
71
+
72
+ class Widget {
73
+ #comm;
74
+ #state;
75
+ #listeners = new Map(); // event -> Set<listener>
76
+
77
+ constructor(comm, state, $display) {
78
+ this.#comm = comm;
79
+ this.#state = state;
80
+ this[$display] = () => ({
81
+ [VIEW_MIME]: { version_major: 2, version_minor: 1, model_id: this.id },
82
+ "text/plain": `Widget(${this.id})`,
83
+ });
84
+ }
85
+
86
+ get id() {
87
+ return this.#comm.id;
88
+ }
89
+
90
+ get closed() {
91
+ return this.#comm.closed;
92
+ }
93
+
94
+ get(key) {
95
+ return this.#state[key];
96
+ }
97
+
98
+ /** set("key", value) or set({ key: value, ... }); syncs to the frontend. */
99
+ set(keyOrChanges, value) {
100
+ const changes = typeof keyOrChanges === "string" ? { [keyOrChanges]: value } : keyOrChanges;
101
+ this.#apply(changes);
102
+ if (this.closed) return;
103
+ const { state, bufferPaths, buffers } = removeBuffers(changes);
104
+ this.#comm.send({ method: "update", state, buffer_paths: bufferPaths }, { buffers });
105
+ }
106
+
107
+ on(event, listener) {
108
+ if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
109
+ this.#listeners.get(event).add(listener);
110
+ }
111
+
112
+ off(event, listener) {
113
+ this.#listeners.get(event)?.delete(listener);
114
+ }
115
+
116
+ /** A custom message, delivered to the view's model.on("msg:custom"). */
117
+ send(content, buffers = []) {
118
+ this.#comm.send({ method: "custom", content }, { buffers });
119
+ }
120
+
121
+ close() {
122
+ this.#comm.close();
123
+ }
124
+
125
+ /** The full state, as the frontend sees it (for request_state). */
126
+ fullState() {
127
+ return { ...ANYWIDGET_MODEL, ...this.#state };
128
+ }
129
+
130
+ async #emit(event, ...args) {
131
+ for (const listener of this.#listeners.get(event) ?? []) await listener(...args);
132
+ }
133
+
134
+ async #apply(changes) {
135
+ const previous = {};
136
+ for (const [key, value] of Object.entries(changes)) {
137
+ previous[key] = this.#state[key];
138
+ this.#state[key] = value;
139
+ }
140
+ for (const key of Object.keys(changes)) await this.#emit(`change:${key}`, changes[key], previous[key]);
141
+ await this.#emit("change", changes);
142
+ }
143
+
144
+ async _receive(msg) {
145
+ const { method } = msg.data;
146
+ if (method === "update") {
147
+ const changes = putBuffers(msg.data.state ?? {}, msg.data.buffer_paths, msg.buffers);
148
+ await this.#apply(changes);
149
+ } else if (method === "request_state") {
150
+ const { state, bufferPaths, buffers } = removeBuffers(this.fullState());
151
+ this.#comm.send({ method: "update", state, buffer_paths: bufferPaths }, { buffers });
152
+ } else if (method === "custom") {
153
+ await this.#emit("msg:custom", msg.data.content, msg.buffers);
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * @param {import("./comm.d.mts").CommApi} commApi
160
+ * @param {symbol} $display
161
+ */
162
+ export function createWidgetFactory(commApi, $display) {
163
+ const live = new Map(); // model id -> Widget
164
+
165
+ // The frontend opens this comm to restore every widget's state, e.g.
166
+ // after the page reloads.
167
+ commApi.registerTarget(CONTROL_TARGET, (control) => {
168
+ control.onMsg((msg) => {
169
+ if (msg.data.method !== "request_states") return;
170
+ const states = {};
171
+ for (const widget of live.values()) {
172
+ const full = widget.fullState();
173
+ states[widget.id] = {
174
+ model_name: full._model_name,
175
+ model_module: full._model_module,
176
+ model_module_version: full._model_module_version,
177
+ state: full,
178
+ };
179
+ }
180
+ const { state, bufferPaths, buffers } = removeBuffers(states);
181
+ control.send({ method: "update_states", states: state, buffer_paths: bufferPaths }, { buffers });
182
+ });
183
+ });
184
+
185
+ return function widget({ esm, css, state = {} } = {}) {
186
+ if (typeof esm !== "string") throw new TypeError("jupyter.widget needs an `esm` module string");
187
+ const initial = { ...state };
188
+ const full = {
189
+ ...ANYWIDGET_MODEL,
190
+ _esm: esm,
191
+ ...(css === undefined ? {} : { _css: css }),
192
+ _anywidget_id: "cdp-kernel.widget",
193
+ ...initial,
194
+ };
195
+ const { state: wire, bufferPaths, buffers } = removeBuffers(full);
196
+ const comm = commApi.open(WIDGET_TARGET, { state: wire, buffer_paths: bufferPaths }, {
197
+ metadata: { version: WIDGET_PROTOCOL_VERSION },
198
+ buffers,
199
+ });
200
+ const instance = new Widget(comm, { ...full }, $display);
201
+ live.set(comm.id, instance);
202
+ comm.onMsg((msg) => instance._receive(msg));
203
+ comm.onClose(() => {
204
+ live.delete(comm.id);
205
+ });
206
+ return instance;
207
+ };
208
+ }