@tlockney/cdp-kernel 0.8.0 → 0.10.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.
@@ -0,0 +1,246 @@
1
+ import type { CommApi } from "./comm.d.mts";
2
+
3
+ /** The target name of the comm a Ganymede server opens to the kernel. */
4
+ export const HOST_TARGET: "ganymede.host.v1";
5
+
6
+ /** Why a host call failed. The codes are ganymede-device's. */
7
+ export type HostErrorCode = "unavailable" | "denied" | "cancelled" | "timeout" | "failed" | "disconnected";
8
+
9
+ /**
10
+ * A failed host call. Branch on `code` (or `name === "HostError"`): the
11
+ * class isn't a global, so `instanceof` isn't available in a cell.
12
+ *
13
+ * A failed host call: `unavailable` (no such method), `denied` (not
14
+ * granted to this notebook), `cancelled` (its signal aborted), `timeout`,
15
+ * `failed` (the server couldn't do it), `disconnected` (the host comm
16
+ * closed, such as on a server restart).
17
+ */
18
+ export class HostError extends Error {
19
+ readonly code: HostErrorCode;
20
+ constructor(code: HostErrorCode, message: string);
21
+ static from(err: unknown): HostError;
22
+ }
23
+
24
+ /** Options for a host call. */
25
+ export interface HostCallOptions {
26
+ /** Aborting it cancels the call. Default `jupyter.signal`. */
27
+ signal?: AbortSignal;
28
+ }
29
+
30
+ /** Parameters and results are JSON; the host API's design note lists each method's. */
31
+ export type HostParams = { [key: string]: unknown };
32
+
33
+ /** An entry `notebooks.list` returns: a folder, or a notebook this notebook may read. */
34
+ export interface NotebookEntry {
35
+ path: string;
36
+ type: "directory" | "notebook";
37
+ /** Its first heading, for a notebook that has one. */
38
+ title?: string | null;
39
+ /** Its kernelspec name. */
40
+ kernel?: string | null;
41
+ /** ISO 8601. */
42
+ modified: string;
43
+ }
44
+
45
+ /** A cell `notebooks.search` matched. */
46
+ export interface NotebookHit {
47
+ path: string;
48
+ title: string | null;
49
+ /** The cell's position in the notebook. */
50
+ index: number;
51
+ id: string | null;
52
+ cell_type: "code" | "markdown" | "raw";
53
+ /** Where the first word matched. */
54
+ in: "source" | "output";
55
+ excerpt: string;
56
+ }
57
+
58
+ /** What `notebooks.search` returns. */
59
+ export interface NotebookSearch {
60
+ hits: NotebookHit[];
61
+ /** Matching cells, beyond `limit` too. */
62
+ total: number;
63
+ truncated: boolean;
64
+ }
65
+
66
+ /** The Ganymede server that runs this kernel, from `jupyter.host()`. */
67
+ export interface Host {
68
+ /** The host API's protocol version. */
69
+ readonly protocol: number;
70
+ /** The server's version. */
71
+ readonly server: string;
72
+ /** This notebook's path under the server's root, or null when the kernel has none. */
73
+ readonly notebook: string | null;
74
+ /** The methods this notebook may call. */
75
+ readonly methods: readonly string[];
76
+ /**
77
+ * Whether this notebook may call `method`.
78
+ *
79
+ * @param method The method, such as `"notebooks.run"`.
80
+ * @example
81
+ * ```ts
82
+ * const host = await jupyter.host();
83
+ * host?.has("notebooks.run"); // false unless the notebook is granted runs
84
+ * ```
85
+ */
86
+ has(method: string): boolean;
87
+ /**
88
+ * Call a method by name. The shortcuts under `notebooks` and `self` call
89
+ * this.
90
+ *
91
+ * @param method The method, such as `"notebooks.read"`.
92
+ * @param params Its parameters, a JSON object. Default `{}`.
93
+ * @param options `signal` cancels the call.
94
+ * @returns What the server answered, as JSON.
95
+ * @throws {HostError} The call failed; `code` says why.
96
+ * @example
97
+ * ```ts
98
+ * const host = await jupyter.host();
99
+ * const { notebook } = await host!.call("notebooks.read", { path: "journal/a.ipynb" });
100
+ * notebook.cells.length;
101
+ * ```
102
+ */
103
+ call(method: string, params?: HostParams, options?: HostCallOptions): Promise<any>;
104
+ /** Other notebooks under the server's root, as far as this notebook's grant reaches. */
105
+ readonly notebooks: HostNotebooks;
106
+ /** This notebook's own cells. Not on a server yet: they reject `unavailable`. */
107
+ readonly self: HostSelf;
108
+ }
109
+
110
+ /** `host.notebooks`: other notebooks under the server's root. */
111
+ export interface HostNotebooks {
112
+ /**
113
+ * The notebooks this notebook may read, and the folders, in a folder.
114
+ *
115
+ * @param params `dir`: the folder, relative to the root. Default the root.
116
+ * @param options `signal` cancels the call.
117
+ * @returns One entry per notebook or folder.
118
+ * @throws {HostError} `denied` when the grant doesn't reach `dir`.
119
+ * @example
120
+ * ```ts
121
+ * const host = await jupyter.host();
122
+ * const entries = await host!.notebooks.list({ dir: "journal" });
123
+ * entries.filter((e) => e.type === "notebook").map((e) => e.title ?? e.path);
124
+ * ```
125
+ */
126
+ list(params?: { dir?: string }, options?: HostCallOptions): Promise<NotebookEntry[]>;
127
+ /**
128
+ * A notebook as nbformat JSON (from its open document when someone has
129
+ * it open), and each cell's source hash by cell id.
130
+ *
131
+ * @param params `path`: the notebook, relative to the root.
132
+ * @param options `signal` cancels the call.
133
+ * @returns The notebook and its hashes.
134
+ * @throws {HostError} `denied` when the grant doesn't reach `path`.
135
+ * @example
136
+ * ```ts
137
+ * const host = await jupyter.host();
138
+ * const { notebook } = await host!.notebooks.read({ path: "journal/a.ipynb" });
139
+ * notebook.cells.length;
140
+ * ```
141
+ */
142
+ read(params: { path: string }, options?: HostCallOptions): Promise<{ notebook: any; hashes: { [cellId: string]: string } }>;
143
+ /**
144
+ * Cells that hold every word of `query`, in the notebooks this notebook
145
+ * may read: in sources, and in outputs too with `outputs: true`.
146
+ *
147
+ * @param params `query`, and optionally `dir`, `outputs`, `cell_type`
148
+ * (`code`, `markdown` or `raw`) and `limit` (default 20, at most 200).
149
+ * @param options `signal` cancels the call.
150
+ * @returns The matches, how many there are in all, and whether `limit` cut them.
151
+ * @example
152
+ * ```ts
153
+ * const host = await jupyter.host();
154
+ * const { hits } = await host!.notebooks.search({ query: "weekday drop", dir: "journal" });
155
+ * hits.map((h) => `${h.path}: ${h.excerpt}`);
156
+ * ```
157
+ */
158
+ search(
159
+ params: { query: string; dir?: string; outputs?: boolean; cell_type?: "code" | "markdown" | "raw"; limit?: number },
160
+ options?: HostCallOptions,
161
+ ): Promise<NotebookSearch>;
162
+ /**
163
+ * A notebook's named results. Not on a server yet: rejects `unavailable`.
164
+ *
165
+ * @param params `path`: the notebook.
166
+ * @param options `signal` cancels the call.
167
+ * @example
168
+ * ```ts
169
+ * const host = await jupyter.host();
170
+ * const results = await host!.notebooks.results({ path: "journal/fremont.ipynb" });
171
+ * ```
172
+ */
173
+ results(params: { path: string }, options?: HostCallOptions): Promise<any>;
174
+ /**
175
+ * Run a notebook with parameters and return its results. Not on a
176
+ * server yet: rejects `unavailable`.
177
+ *
178
+ * @param params `path`: the notebook; `params`: its parameters.
179
+ * @param options `signal` cancels the run.
180
+ * @example
181
+ * ```ts
182
+ * const host = await jupyter.host();
183
+ * const run = await host!.notebooks.run({ path: "journal/fremont.ipynb", params: { counter: "2nd Ave" } });
184
+ * ```
185
+ */
186
+ run(params: { path: string; params?: HostParams }, options?: HostCallOptions): Promise<any>;
187
+ }
188
+
189
+ /** `host.self`: this notebook's own cells. Not on a server yet: each rejects `unavailable`. */
190
+ export interface HostSelf {
191
+ /**
192
+ * Add a cell to this notebook.
193
+ *
194
+ * @param params The cell, as the host API's design note describes.
195
+ * @param options `signal` cancels the call.
196
+ * @example
197
+ * ```ts
198
+ * const host = await jupyter.host();
199
+ * await host!.self.add({ cell_type: "markdown", source: "## Index" });
200
+ * ```
201
+ */
202
+ add(params?: HostParams, options?: HostCallOptions): Promise<any>;
203
+ /**
204
+ * Change a cell's source, given the hash of the source it replaces.
205
+ *
206
+ * @param params The cell id, its new source and the old source's hash.
207
+ * @param options `signal` cancels the call.
208
+ * @example
209
+ * ```ts
210
+ * const host = await jupyter.host();
211
+ * const { notebook, hashes } = await host!.notebooks.read({ path: host!.notebook! });
212
+ * const id = notebook.cells[0].id;
213
+ * await host!.self.edit({ id, source: "# New title", source_hash: hashes[id] });
214
+ * ```
215
+ */
216
+ edit(params?: HostParams, options?: HostCallOptions): Promise<any>;
217
+ /**
218
+ * Delete a cell, given its source's hash.
219
+ *
220
+ * @param params The cell id and its source's hash.
221
+ * @param options `signal` cancels the call.
222
+ * @example
223
+ * ```ts
224
+ * const host = await jupyter.host();
225
+ * const { notebook, hashes } = await host!.notebooks.read({ path: host!.notebook! });
226
+ * const id = notebook.cells.at(-1).id;
227
+ * await host!.self.delete({ id, source_hash: hashes[id] });
228
+ * ```
229
+ */
230
+ delete(params?: HostParams, options?: HostCallOptions): Promise<any>;
231
+ }
232
+
233
+ /** Options for `jupyter.host()`. */
234
+ export interface HostOptions {
235
+ /**
236
+ * How long to wait for a server that hasn't opened the comm, in ms.
237
+ * Default 1000. Once a wait goes unanswered, later calls answer `null`
238
+ * at once.
239
+ */
240
+ timeout?: number;
241
+ }
242
+
243
+ export function createHost(
244
+ comms: CommApi,
245
+ defaultSignal?: () => AbortSignal | undefined,
246
+ ): { host(options?: HostOptions): Promise<Host | null> };
@@ -0,0 +1,215 @@
1
+ // @ts-self-types="./host.d.mts"
2
+ // jupyter.host() (#461): the client for a Ganymede server's host API, which
3
+ // lets a cell read, search and (when granted) run other notebooks. The
4
+ // server opens a comm to target `ganymede.host.v1` when the kernel starts,
5
+ // with its `hello` as the open's data. Calls go out as comm messages
6
+ // `{ id, method, params }`; the server replies `{ id, result }` or
7
+ // `{ id, error: { code, message } }` on a kernel subshell, so a cell that
8
+ // awaits a call gets its reply. The message format and error codes are
9
+ // ganymede-device's, so the two clients behave alike.
10
+ //
11
+ // Design: https://github.com/tlockney/ganymede/blob/main/docs/host-api.md
12
+
13
+ export const HOST_TARGET = "ganymede.host.v1";
14
+
15
+ const CODES = new Set(["unavailable", "denied", "cancelled", "timeout", "failed", "disconnected"]);
16
+
17
+ export class HostError extends Error {
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.name = "HostError";
21
+ this.code = code;
22
+ }
23
+
24
+ /** An abort becomes `cancelled`, a timeout signal's abort `timeout`, anything else `failed`. */
25
+ static from(err) {
26
+ if (err instanceof HostError) return err;
27
+ const message = typeof err?.message === "string" ? err.message : String(err);
28
+ if (err?.name === "AbortError") return new HostError("cancelled", message);
29
+ if (err?.name === "TimeoutError") return new HostError("timeout", message);
30
+ if (typeof err?.code === "string" && CODES.has(err.code)) return new HostError(err.code, message);
31
+ return new HostError("failed", message);
32
+ }
33
+ }
34
+
35
+ /** Why an aborted signal ended a call: `timeout` for a timeout signal, else `cancelled`. */
36
+ function abortError(reason) {
37
+ const message = typeof reason?.message === "string" ? reason.message : String(reason ?? "aborted");
38
+ return new HostError(reason?.name === "TimeoutError" ? "timeout" : "cancelled", message);
39
+ }
40
+
41
+ /** A server's `hello`, or null when `data` isn't one. */
42
+ function helloOf(data) {
43
+ if (data === null || typeof data !== "object" || typeof data.protocol !== "number") return null;
44
+ return {
45
+ protocol: data.protocol,
46
+ server: typeof data.server === "string" ? data.server : "",
47
+ notebook: typeof data.notebook === "string" ? data.notebook : null,
48
+ methods: Array.isArray(data.methods) ? data.methods.filter((m) => typeof m === "string") : [],
49
+ };
50
+ }
51
+
52
+ /**
53
+ * @param {import("./comm.d.mts").CommApi} comms
54
+ * @param {() => AbortSignal | undefined} [defaultSignal] the signal a call
55
+ * watches when it is given none: jupyter.signal.
56
+ */
57
+ export function createHost(comms, defaultSignal = () => undefined) {
58
+ /** The open host comm: { comm, hello, host, pending }, or null. */
59
+ let link = null;
60
+ /** Whoever waits for a hello: called with the link's Host, or null once there is no link. */
61
+ const waiters = new Set();
62
+ /**
63
+ * A comm this runtime opened went unanswered: no server. Remembered, so
64
+ * each `jupyter.host()` after the first answers null at once, until a
65
+ * server opens the comm itself.
66
+ */
67
+ let noServer = false;
68
+ let nextId = 0;
69
+
70
+ function settle() {
71
+ const host = link?.host ?? null;
72
+ if (host === null && link !== null) return;
73
+ for (const waiter of [...waiters]) waiter(host);
74
+ }
75
+
76
+ /** `ours`: this runtime opened the comm, not the server. */
77
+ function adopt(comm, data, ours = false) {
78
+ // A new comm replaces the old one: the server opens one per kernel start.
79
+ const old = link;
80
+ const l = { comm, host: null, pending: new Map(), ours };
81
+ if (!ours) noServer = false;
82
+ link = l;
83
+ if (old !== null && !old.comm.closed) old.comm.close();
84
+ const hello = helloOf(data);
85
+ if (hello !== null) l.host = makeHost(l, hello);
86
+ comm.onMsg(({ data }) => {
87
+ if (data.hello !== undefined) {
88
+ const hello = helloOf(data.hello);
89
+ if (hello !== null) {
90
+ l.host = makeHost(l, hello);
91
+ if (link === l) settle();
92
+ }
93
+ return;
94
+ }
95
+ const call = l.pending.get(data.id);
96
+ if (call === undefined) return;
97
+ if (data.error !== undefined) {
98
+ const { code, message } = data.error ?? {};
99
+ call.reject(new HostError(CODES.has(code) ? code : "failed", typeof message === "string" ? message : String(code)));
100
+ } else {
101
+ call.resolve(data.result);
102
+ }
103
+ });
104
+ comm.onClose(({ data }) => {
105
+ for (const call of l.pending.values()) {
106
+ call.reject(new HostError("disconnected", typeof data?.error === "string" ? `host comm closed: ${data.error}` : "host comm closed"));
107
+ }
108
+ l.pending.clear();
109
+ if (link === l) {
110
+ link = null;
111
+ // The frontend closed the comm this runtime opened: no server.
112
+ if (l.ours && l.host === null) noServer = true;
113
+ settle();
114
+ }
115
+ });
116
+ if (l.host !== null) settle();
117
+ }
118
+
119
+ function makeHost(l, hello) {
120
+ const methods = Object.freeze([...hello.methods]);
121
+ function call(method, params = {}, { signal = defaultSignal() } = {}) {
122
+ if (signal?.aborted) return Promise.reject(abortError(signal.reason));
123
+ if (l.comm.closed) return Promise.reject(new HostError("disconnected", "host comm closed"));
124
+ const id = nextId++;
125
+ return new Promise((resolve, reject) => {
126
+ const onAbort = () => {
127
+ l.pending.delete(id);
128
+ if (!l.comm.closed) l.comm.send({ id, cancel: true });
129
+ reject(abortError(signal.reason));
130
+ };
131
+ const done = () => signal?.removeEventListener("abort", onAbort);
132
+ const call = {
133
+ resolve: (value) => (l.pending.delete(id), done(), resolve(value)),
134
+ reject: (error) => (l.pending.delete(id), done(), reject(error)),
135
+ };
136
+ l.pending.set(id, call);
137
+ signal?.addEventListener("abort", onAbort, { once: true });
138
+ try {
139
+ l.comm.send({ id, method, params });
140
+ } catch (err) {
141
+ // Params that can't be sent, such as a BigInt: the server never
142
+ // got the call.
143
+ call.reject(new HostError("failed", `cannot send ${method}: ${err instanceof Error ? err.message : err}`));
144
+ }
145
+ });
146
+ }
147
+ const bind = (method) => (params, options) => call(method, params, options);
148
+ return Object.freeze({
149
+ protocol: hello.protocol,
150
+ server: hello.server,
151
+ notebook: hello.notebook,
152
+ methods,
153
+ has: (method) => methods.includes(method),
154
+ call,
155
+ notebooks: Object.freeze({
156
+ list: bind("notebooks.list"),
157
+ read: bind("notebooks.read"),
158
+ search: bind("notebooks.search"),
159
+ results: bind("notebooks.results"),
160
+ run: bind("notebooks.run"),
161
+ }),
162
+ self: Object.freeze({
163
+ add: bind("self.add"),
164
+ edit: bind("self.edit"),
165
+ delete: bind("self.delete"),
166
+ }),
167
+ });
168
+ }
169
+
170
+ comms.registerTarget(HOST_TARGET, (comm, msg) => adopt(comm, msg.data));
171
+
172
+ async function host({ timeout = 1000 } = {}) {
173
+ if (link?.host) return link.host;
174
+ // The wait for a server watches jupyter.signal, so an interrupt ends it.
175
+ const signal = defaultSignal();
176
+ if (signal?.aborted) throw abortError(signal.reason);
177
+ if (noServer) return null;
178
+ // No comm from the server (one whose comm closed, as when a runtime
179
+ // restarts in the same kernel): open it from here. A Ganymede server
180
+ // answers `{ hello }`; anything else closes it or stays silent.
181
+ if (link === null) adopt(comms.open(HOST_TARGET), null, true);
182
+ const mine = link;
183
+ return new Promise((resolve, reject) => {
184
+ let timer;
185
+ const onAbort = () => {
186
+ done();
187
+ reject(abortError(signal.reason));
188
+ };
189
+ const done = () => {
190
+ waiters.delete(waiter);
191
+ clearTimeout(timer);
192
+ signal?.removeEventListener("abort", onAbort);
193
+ };
194
+ const waiter = (host) => {
195
+ done();
196
+ resolve(host);
197
+ };
198
+ waiters.add(waiter);
199
+ signal?.addEventListener("abort", onAbort, { once: true });
200
+ // Each caller waits its own timeout; the last to give up on a comm
201
+ // this runtime opened closes it.
202
+ timer = setTimeout(() => {
203
+ done();
204
+ if (waiters.size === 0 && link === mine && mine.host === null && mine.ours) {
205
+ link = null;
206
+ noServer = true;
207
+ mine.comm.close();
208
+ }
209
+ resolve(null);
210
+ }, timeout);
211
+ });
212
+ }
213
+
214
+ return { host };
215
+ }
@@ -5,31 +5,230 @@ export const WIDGET_PROTOCOL_VERSION: "2.1.0";
5
5
 
6
6
  export type WidgetState = { [key: string]: unknown };
7
7
 
8
+ /** Options for `jupyter.widget`. */
8
9
  export interface WidgetOptions {
9
- /** The anywidget ES module: `export default { render({ model, el }) { ... } }`. */
10
+ /**
11
+ * The anywidget view module, as source:
12
+ * `export default { render({ model, el }) { ... } }`. A `${...}` inside
13
+ * a template literal here is filled in by the kernel, not the browser:
14
+ * escape it as `\${...}`.
15
+ */
10
16
  esm: string;
11
- /** Optional CSS for the view. */
17
+ /** CSS for the view. */
12
18
  css?: string;
13
- /** Initial synced state. Uint8Array/ArrayBuffer values travel as binary buffers. */
19
+ /**
20
+ * The initial synced state. `Uint8Array` and `ArrayBuffer` values travel
21
+ * as binary buffers. Default `{}`.
22
+ */
14
23
  state?: WidgetState;
15
24
  }
16
25
 
17
- /** An anywidget model, synced with its frontend views. Display it to show a view. */
26
+ /**
27
+ * An anywidget model, from `jupyter.widget`, synced with its frontend
28
+ * views. Display it to show a view; display it again for a second view of
29
+ * the same model.
30
+ */
18
31
  export interface Widget {
19
32
  /** The model id (the comm id). */
20
33
  readonly id: string;
34
+ /** Whether the model has been closed. */
21
35
  readonly closed: boolean;
36
+ /**
37
+ * The current value of a state key.
38
+ *
39
+ * @param key The state key.
40
+ * @returns Its value, or `undefined`.
41
+ * @example
42
+ * ```ts
43
+ * const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
44
+ * w.get("count"); // 0
45
+ * ```
46
+ */
22
47
  get(key: string): unknown;
23
- /** Sets one key or several, and syncs the change to the frontend. */
48
+ /**
49
+ * Change one state key, or several at once, and sync the change to every
50
+ * view. Fires this model's own `change` listeners too. After `close` it
51
+ * changes the local state only.
52
+ *
53
+ * @param key The state key.
54
+ * @param value Its new value.
55
+ * @param changes Several keys and their new values, instead of `key` and
56
+ * `value`.
57
+ * @example
58
+ * ```ts
59
+ * const w = jupyter.widget({ esm: "export default {}", state: { count: 0, label: "" } });
60
+ * w.set("count", 1);
61
+ * w.set({ count: 2, label: "two" });
62
+ * ```
63
+ * @example
64
+ * ```ts
65
+ * const progress = jupyter.widget({
66
+ * esm: `export default {
67
+ * render({ model, el }) {
68
+ * el.innerHTML =
69
+ * '<div class="progress-track"><div class="progress-fill"></div></div>' +
70
+ * '<div class="progress-label"></div>';
71
+ * const fill = el.querySelector(".progress-fill");
72
+ * const label = el.querySelector(".progress-label");
73
+ * const update = () => {
74
+ * const percent = Math.round(model.get("value") * 100);
75
+ * fill.style.width = percent + "%";
76
+ * label.textContent = model.get("message") + " (" + percent + "%)";
77
+ * };
78
+ * model.on("change:value", update);
79
+ * model.on("change:message", update);
80
+ * update();
81
+ * },
82
+ * };`,
83
+ * css: `
84
+ * .progress-track { width: 320px; height: 10px; border-radius: 5px; background: #e5e7eb; overflow: hidden; }
85
+ * .progress-fill { height: 100%; background: #2e6e64; transition: width 0.2s; }
86
+ * .progress-label { margin-top: 6px; font: 13px system-ui, sans-serif; color: #444; }
87
+ * `,
88
+ * state: { value: 0, message: "Starting" },
89
+ * });
90
+ * await jupyter.display(progress);
91
+ *
92
+ * const files = ["january.csv", "february.csv", "march.csv", "april.csv", "may.csv"];
93
+ * for (const [i, file] of files.entries()) {
94
+ * progress.set({ message: "Processing " + file, value: i / files.length });
95
+ * await new Promise((resolve) => setTimeout(resolve, 600)); // stand-in for real work
96
+ * }
97
+ * progress.set({ message: "Done", value: 1 });
98
+ * ```
99
+ * ![A progress bar three fifths full, labelled "Processing april.csv (60%)", captured while the cell was still running.](https://cdp-kernel.org/images/widgets/progress.png)
100
+ */
24
101
  set(key: string, value: unknown): void;
25
102
  set(changes: WidgetState): void;
103
+ /**
104
+ * Listen for state changes or custom messages from the views.
105
+ * `change:key` gets the new and previous value; `change` gets all the
106
+ * changed keys; `msg:custom` gets a view's `model.send` content.
107
+ *
108
+ * @param event `change:<key>`, `change` or `msg:custom`.
109
+ * @param listener Called for each event; may be async.
110
+ * @example
111
+ * ```ts
112
+ * const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
113
+ * w.on("change:count", (value, previous) => console.log(previous, "->", value));
114
+ * w.on("msg:custom", (content) => console.log("view sent", content));
115
+ * ```
116
+ * @example
117
+ * ```ts
118
+ * const temperature = jupyter.widget({
119
+ * esm: `export default {
120
+ * render({ model, el }) {
121
+ * const slider = document.createElement("input");
122
+ * slider.type = "range";
123
+ * slider.min = "-20";
124
+ * slider.max = "40";
125
+ * const label = document.createElement("span");
126
+ * const update = () => {
127
+ * slider.value = String(model.get("celsius"));
128
+ * label.textContent = " " + model.get("celsius") + " °C is " + model.get("fahrenheit") + " °F";
129
+ * };
130
+ * slider.oninput = () => {
131
+ * model.set("celsius", slider.valueAsNumber);
132
+ * model.save_changes();
133
+ * };
134
+ * model.on("change:celsius", update);
135
+ * model.on("change:fahrenheit", update);
136
+ * update();
137
+ * el.append(slider, label);
138
+ * },
139
+ * };`,
140
+ * state: { celsius: 20, fahrenheit: 68 },
141
+ * });
142
+ *
143
+ * temperature.on("change:celsius", (celsius) => {
144
+ * temperature.set("fahrenheit", Math.round((Number(celsius) * 9) / 5 + 32));
145
+ * });
146
+ *
147
+ * temperature;
148
+ * ```
149
+ * ![A range slider moved to 30, labelled "30 °C is 86 °F".](https://cdp-kernel.org/images/widgets/temperature.png)
150
+ */
26
151
  on(event: `change:${string}`, listener: (value: unknown, previous: unknown) => void | Promise<void>): void;
27
152
  on(event: "change", listener: (changes: WidgetState) => void | Promise<void>): void;
28
153
  on(event: "msg:custom", listener: (content: unknown, buffers: Uint8Array[]) => void | Promise<void>): void;
154
+ /**
155
+ * Remove a listener added with `on`.
156
+ *
157
+ * @param event The event it was added for.
158
+ * @param listener The same function.
159
+ * @example
160
+ * ```ts
161
+ * const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
162
+ * const log = (value: unknown) => console.log(value);
163
+ * w.on("change:count", log);
164
+ * w.off("change:count", log);
165
+ * ```
166
+ */
29
167
  off(event: string, listener: (...args: never[]) => unknown): void;
30
- /** A custom message to the view's `model.on("msg:custom", ...)`. */
168
+ /**
169
+ * Send a custom message to every view's `model.on("msg:custom", ...)`.
170
+ *
171
+ * @param content JSON content.
172
+ * @param buffers Binary buffers. Default `[]`.
173
+ * @example
174
+ * ```ts
175
+ * const w = jupyter.widget({ esm: "export default {}" });
176
+ * w.send({ type: "reset" });
177
+ * ```
178
+ * @example
179
+ * ```ts
180
+ * const dice = jupyter.widget({
181
+ * esm: `export default {
182
+ * render({ model, el }) {
183
+ * const button = document.createElement("button");
184
+ * button.textContent = "Roll 3 dice";
185
+ * button.onclick = () => model.send({ count: 3 });
186
+ * const result = document.createElement("span");
187
+ * result.style.marginLeft = "12px";
188
+ * result.style.fontSize = "24px";
189
+ * model.on("msg:custom", (message) => {
190
+ * result.textContent = message.faces + " (total " + message.total + ")";
191
+ * });
192
+ * el.append(button, result);
193
+ * },
194
+ * };`,
195
+ * });
196
+ *
197
+ * dice.on("msg:custom", (content) => {
198
+ * const { count } = content as { count: number };
199
+ * const rolls = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * 6));
200
+ * dice.send({
201
+ * faces: rolls.map((n) => "⚀⚁⚂⚃⚄⚅"[n - 1]).join(" "),
202
+ * total: rolls.reduce((a, b) => a + b, 0),
203
+ * });
204
+ * });
205
+ *
206
+ * dice;
207
+ * ```
208
+ * ![A "Roll 3 dice" button next to three die faces and their total, the kernel's answer to a click.](https://cdp-kernel.org/images/widgets/dice.png)
209
+ */
31
210
  send(content: unknown, buffers?: Uint8Array[]): void;
211
+ /**
212
+ * Close the model. Its views stop syncing.
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * const w = jupyter.widget({ esm: "export default {}" });
217
+ * w.close();
218
+ * ```
219
+ */
32
220
  close(): void;
221
+ /**
222
+ * The full state, as the frontend sees it: anywidget's model keys
223
+ * (`_model_name`, `_view_name` and the rest) and the widget's own.
224
+ *
225
+ * @returns Every state key and its value.
226
+ * @example
227
+ * ```ts
228
+ * const w = jupyter.widget({ esm: "export default {}", state: { count: 0 } });
229
+ * w.fullState(); // { _model_name: "AnyModel", _model_module: "anywidget", ..., count: 0 }
230
+ * ```
231
+ */
33
232
  fullState(): WidgetState;
34
233
  /** Displaying a widget shows a view of it (application/vnd.jupyter.widget-view+json). */
35
234
  [$display](): MediaBundle;