@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.
@@ -1,10 +1,21 @@
1
1
  import type { CommApi, Comms } from "./comm.d.mts";
2
+ import type { Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch } from "./host.d.mts";
2
3
  import type { Widget, WidgetOptions } from "./widget.d.mts";
3
4
 
4
- export type { CommApi, Widget, WidgetOptions };
5
+ export type { CommApi, Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch, Widget, WidgetOptions };
5
6
 
7
+ /**
8
+ * The symbol a class implements to choose how its instances appear. At
9
+ * runtime it is `Symbol.for("Jupyter.display")`, so a library can use that
10
+ * form without the `jupyter` global.
11
+ */
6
12
  export const $display: unique symbol;
7
13
 
14
+ /**
15
+ * A Jupyter MIME bundle: media type to content, such as
16
+ * `{ "text/plain": "42", "text/html": "<b>42</b>" }`. Binary images are
17
+ * base64 strings; JSON types (`application/json`, Vega-Lite) are objects.
18
+ */
8
19
  export type MediaBundle = { [mediaType: string]: unknown };
9
20
 
10
21
  export interface DisplayHost {
@@ -13,41 +24,339 @@ export interface DisplayHost {
13
24
  readFile: (path: string) => Uint8Array;
14
25
  send: (msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void;
15
26
  input: (prompt: string, password: boolean) => string | null;
27
+ /** The run's parameters as JSON text, or `undefined` when there are none. */
28
+ params?: () => string | undefined;
16
29
  }
17
30
 
31
+ /** The MIME type of a named result: `{ name, value }`. */
32
+ export const RESULT_MIME: "application/vnd.ganymede.result+json";
33
+
34
+ /** A JSON value, as `JSON.parse` returns it. */
35
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
36
+
37
+ /** Options for `jupyter.display`. */
18
38
  export interface DisplayOptions {
39
+ /**
40
+ * Send a plain object as the MIME bundle itself instead of formatting
41
+ * it. Default `false`.
42
+ */
19
43
  raw?: boolean;
44
+ /**
45
+ * Replace every earlier output that has the same `display_id`, including
46
+ * one shown by an earlier cell, instead of adding an output. Needs
47
+ * `display_id`. Default `false`.
48
+ */
20
49
  update?: boolean;
50
+ /** An id that a later display with `update: true` can replace. */
21
51
  display_id?: string;
22
52
  }
23
53
 
54
+ /** A value that renders as rich output: it has a `[jupyter.$display]` method. */
24
55
  export interface Displayable {
56
+ /** Returns the value's MIME bundle, or a promise of one. */
25
57
  [$display]: () => MediaBundle | Promise<MediaBundle>;
26
58
  }
27
59
 
60
+ /**
61
+ * The `jupyter` global: how values appear in the notebook. The same object
62
+ * as `Deno.jupyter` in `deno jupyter`.
63
+ */
28
64
  export interface JupyterNamespace {
65
+ /**
66
+ * Send a raw Jupyter message on the kernel's output channel (IOPub), as a
67
+ * reply to the running cell. The other helpers are built on it. It does
68
+ * not check the message: a wrong field is ignored or mishandled by the
69
+ * frontend.
70
+ *
71
+ * @param msgType An IOPub message type, such as `"clear_output"` or
72
+ * `"display_data"`.
73
+ * @param content The message content, as the messaging spec defines it
74
+ * for `msgType`.
75
+ * @param options `metadata` for the message, and binary `buffers`.
76
+ * @example
77
+ * ```ts
78
+ * // Count down in place: clear_output with wait avoids a flicker.
79
+ * for (const n of [3, 2, 1]) {
80
+ * jupyter.broadcast("clear_output", { wait: true });
81
+ * await jupyter.display(jupyter.md`Launching in **${n}**…`);
82
+ * await new Promise((resolve) => setTimeout(resolve, 1000));
83
+ * }
84
+ * ```
85
+ * @see jupyter-api.md#raw-messages-with-broadcast
86
+ */
29
87
  broadcast(
30
88
  msgType: string,
31
89
  content: object,
32
90
  options?: { metadata?: object; buffers?: Uint8Array[] },
33
91
  ): void;
92
+ /**
93
+ * Show a value now, in the middle of a cell, or replace one shown
94
+ * earlier. The value can be anything a cell could end with: a template
95
+ * from `md`, `html` or `svg`, an image, a widget, an object with a
96
+ * `[jupyter.$display]` method, or a plain value, shown as text.
97
+ *
98
+ * @param obj The value to show.
99
+ * @param options `display_id` and `update` to replace an earlier output;
100
+ * `raw` to send a plain object as the MIME bundle itself.
101
+ * @returns Resolves once the output is sent. Without `await` the output
102
+ * still appears, but later output in the cell can appear before it.
103
+ * @example
104
+ * ```ts
105
+ * const bar = (pct: number) => jupyter.html`<progress value="${pct}" max="100"></progress>`;
106
+ * await jupyter.display(bar(0), { display_id: "download" });
107
+ * for (let pct = 25; pct <= 100; pct += 25) {
108
+ * await new Promise((resolve) => setTimeout(resolve, 500));
109
+ * await jupyter.display(bar(pct), { display_id: "download", update: true });
110
+ * }
111
+ * ```
112
+ * @example
113
+ * ```ts
114
+ * // A MIME bundle sent as is: JupyterLab renders it as a JSON tree.
115
+ * const user = { name: "Ada", active: true };
116
+ * await jupyter.display(
117
+ * { "application/json": user, "text/plain": JSON.stringify(user) },
118
+ * { raw: true },
119
+ * );
120
+ * ```
121
+ * @see jupyter-api.md#showing-output-with-display
122
+ */
34
123
  display(obj: unknown, options?: DisplayOptions): Promise<void>;
124
+ /**
125
+ * The MIME bundle that displaying a value would send, without showing
126
+ * anything. For plain values `text/plain` holds terminal color codes,
127
+ * as the notebook shows them.
128
+ *
129
+ * @param obj The value to format.
130
+ * @returns The bundle, for example `{ "text/plain": "\x1b[33m42\x1b[39m" }`
131
+ * for `42`.
132
+ * @example
133
+ * ```ts
134
+ * const bundle = await jupyter.format(jupyter.md`# Weekly report`);
135
+ * bundle["text/markdown"]; // "# Weekly report"
136
+ * ```
137
+ * @see jupyter-api.md#getting-a-bundle-with-format
138
+ */
35
139
  format(obj: unknown): Promise<MediaBundle>;
140
+ /**
141
+ * A tagged template that renders as Markdown. Interpolated values are
142
+ * inserted as is, without escaping.
143
+ *
144
+ * @returns A value that shows as `text/markdown` as a cell's result or
145
+ * through `display`.
146
+ * @example
147
+ * ```ts
148
+ * const scores = { Ada: 92, Grace: 88 };
149
+ * const rows = Object.entries(scores).map(([n, s]) => `| ${n} | ${s} |`).join("\n");
150
+ * jupyter.md`| Name | Score |
151
+ * | --- | --- |
152
+ * ${rows}`;
153
+ * ```
154
+ * @see jupyter-api.md#markdown
155
+ */
36
156
  md(strings: TemplateStringsArray, ...values: unknown[]): Displayable;
157
+ /**
158
+ * A tagged template that renders as HTML. Interpolated values are
159
+ * inserted as is, without escaping: escape text from users, files or
160
+ * the network yourself.
161
+ *
162
+ * @returns A value that shows as `text/html`.
163
+ * @example
164
+ * ```ts
165
+ * const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
166
+ * const comment = "<script>alert(1)</script>";
167
+ * jupyter.html`<p>${escape(comment)}</p>`;
168
+ * ```
169
+ * @see jupyter-api.md#html
170
+ */
37
171
  html(strings: TemplateStringsArray, ...values: unknown[]): Displayable;
172
+ /**
173
+ * A tagged template that renders as SVG. Include the `xmlns` attribute.
174
+ * Interpolated values are inserted as is, without escaping.
175
+ *
176
+ * @returns A value that shows as `image/svg+xml`.
177
+ * @example
178
+ * ```ts
179
+ * const data = [4, 8, 15, 16];
180
+ * const bars = data.map((v, i) => `<rect x="${i * 20}" y="${40 - v * 2}" width="16" height="${v * 2}"/>`).join("");
181
+ * jupyter.svg`<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40">${bars}</svg>`;
182
+ * ```
183
+ * @see jupyter-api.md#svg
184
+ */
38
185
  svg(strings: TemplateStringsArray, ...values: unknown[]): Displayable;
186
+ /**
187
+ * Show a PNG or JPEG. The format is read from the bytes, not the file
188
+ * name.
189
+ *
190
+ * @param obj A file path, relative to the notebook's directory, or the
191
+ * image's bytes.
192
+ * @returns A value that shows as `image/png` or `image/jpeg`.
193
+ * @throws {TypeError} The bytes are not PNG or JPEG, or the path can't
194
+ * be read.
195
+ * @availability Paths need a file system: on workerd and celld pass
196
+ * bytes.
197
+ * @example
198
+ * ```ts
199
+ * jupyter.image("./chart.png");
200
+ * ```
201
+ * @example
202
+ * ```ts
203
+ * const response = await fetch("https://www.python.org/static/img/python-logo.png");
204
+ * jupyter.image(new Uint8Array(await response.arrayBuffer()));
205
+ * ```
206
+ * @see jupyter-api.md#images
207
+ */
39
208
  image(obj: string | Uint8Array): Displayable;
209
+ /**
210
+ * The symbol a class implements to choose how its instances appear. The
211
+ * method returns a MIME bundle, or a promise of one. It is used for a
212
+ * cell's result, `display` and `format`, but not for objects nested in
213
+ * other values. At runtime it is `Symbol.for("Jupyter.display")`.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * class Temperature {
218
+ * celsius: number;
219
+ * constructor(celsius: number) {
220
+ * this.celsius = celsius;
221
+ * }
222
+ * [jupyter.$display]() {
223
+ * return { "text/plain": `${this.celsius}°C`, "text/html": `<b>${this.celsius}°C</b>` };
224
+ * }
225
+ * }
226
+ * new Temperature(21);
227
+ * ```
228
+ * @see jupyter-api.md#custom-display-for-your-own-classes
229
+ */
40
230
  $display: typeof $display;
41
- /** Jupyter comms: bidirectional channels to the frontend. */
231
+ /**
232
+ * Jupyter comms: message channels to the frontend outside cell
233
+ * execution. Its methods are in the widgets and comms reference.
234
+ *
235
+ * @see widgets.md#comms
236
+ */
42
237
  comm: CommApi;
43
- /** An anywidget-compatible widget; display the result to show it. */
238
+ /**
239
+ * Create an anywidget-compatible widget model. Display it (or end the
240
+ * cell with it) to show a view.
241
+ *
242
+ * @param options The view module, its CSS, and the initial state.
243
+ * @returns The model, synced with every view of it.
244
+ * @throws {TypeError} `options.esm` is not a string.
245
+ * @availability The frontend needs the anywidget extension.
246
+ * @example
247
+ * ```ts
248
+ * const counter = jupyter.widget({
249
+ * esm: `export default {
250
+ * render({ model, el }) {
251
+ * const button = document.createElement("button");
252
+ * const update = () => {
253
+ * button.textContent = "count is " + model.get("count");
254
+ * };
255
+ * button.onclick = () => {
256
+ * model.set("count", model.get("count") + 1);
257
+ * model.save_changes();
258
+ * };
259
+ * model.on("change:count", update);
260
+ * update();
261
+ * el.append(button);
262
+ * },
263
+ * };`,
264
+ * state: { count: 0 },
265
+ * });
266
+ * counter; // the cell's result: displays the widget
267
+ * ```
268
+ * ![A JupyterLab cell that creates a counter widget with jupyter.widget. Below the code, the output is a button reading "count is 3" after three clicks.](https://cdp-kernel.org/images/widgets/counter.png)
269
+ * @see widgets.md#a-first-widget-a-counter
270
+ */
44
271
  widget(options: WidgetOptions): Widget;
45
272
  /**
46
- * Aborted when the kernel interrupts this cell. Each cell gets its own,
47
- * as does a comm handler that starts while no cell runs. Read it when
48
- * the work starts: a later read gets the signal of whatever started last.
273
+ * An `AbortSignal` that an interrupt aborts, so code waiting on something
274
+ * only it can end can stop and clean up. Each cell gets its own, as does
275
+ * a comm handler that starts while no cell runs. Read it once when the
276
+ * work starts: a later read gets the signal of whatever started last.
277
+ * The abort reason is a `DOMException` named `AbortError`.
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * const response = await fetch("https://example.com/large.csv", { signal: jupyter.signal });
282
+ * ```
283
+ * @example
284
+ * ```ts
285
+ * const signal = jupyter.signal;
286
+ * const ask = jupyter.comm.open("my.app", { question: "ready?" });
287
+ * const answer = await new Promise((resolve, reject) => {
288
+ * ask.onMsg((m) => resolve(m.data));
289
+ * signal.addEventListener("abort", () => { ask.close(); reject(signal.reason); }, { once: true });
290
+ * });
291
+ * ```
292
+ * @see jupyter-api.md#stopping-on-interrupt-jupyter-signal
49
293
  */
50
294
  readonly signal: AbortSignal;
295
+ /**
296
+ * The parameters of this run: a frozen object, empty unless whoever
297
+ * started the kernel passed some, such as `ganymede execute -p
298
+ * counter="2nd Ave"`. They arrive as a JSON object in the kernel's
299
+ * `GANYMEDE_PARAMS` environment variable. Take defaults with
300
+ * destructuring, so the notebook also runs on its own.
301
+ *
302
+ * @throws {TypeError} `GANYMEDE_PARAMS` is set but isn't a JSON object.
303
+ * @availability Node, Deno, Bun, workerd and celld read the kernel's
304
+ * environment; the browser target takes `params` from its options.
305
+ * @example
306
+ * ```ts
307
+ * const { counter = "Fremont", days = 30 } = jupyter.params as { counter?: string; days?: number };
308
+ * `${counter}, last ${days} days`;
309
+ * ```
310
+ * @see jupyter-api.md#parameters-and-named-results
311
+ */
312
+ readonly params: { readonly [name: string]: JsonValue };
313
+ /**
314
+ * Publish a value under a name, as an output that a run's caller reads
315
+ * by name instead of parsing outputs: `ganymede execute --results`, or
316
+ * another notebook. The output has the MIME type
317
+ * `application/vnd.ganymede.result+json` holding `{ name, value }`, and
318
+ * a `text/plain` line `name = value` for frontends that don't know the
319
+ * type. When a name is published twice, readers take the last one.
320
+ *
321
+ * @param name The result's name.
322
+ * @param value Any value `JSON.stringify` accepts; it is stored as JSON,
323
+ * so a `Date` becomes its ISO string.
324
+ * @throws {TypeError} `name` is empty, or `value` isn't JSON (a
325
+ * function, `undefined`, a `BigInt` or a cycle).
326
+ * @example
327
+ * ```ts
328
+ * const counts = [3120, 2890, 2410];
329
+ * jupyter.result("weekday_drop", (counts[2] - counts[0]) / counts[0]);
330
+ * jupyter.result("counts", counts);
331
+ * ```
332
+ * @see jupyter-api.md#parameters-and-named-results
333
+ */
334
+ result(name: string, value: unknown): void;
335
+ /**
336
+ * The Ganymede server running this kernel, through its host API: read,
337
+ * search and (when granted) run other notebooks under its root. The
338
+ * server opens a comm to the kernel when it starts; this resolves to
339
+ * `null` when none answers, as under plain Jupyter.
340
+ *
341
+ * Each call returns a promise and takes `{ signal }`, by default
342
+ * `jupyter.signal`, so an interrupt cancels it. A failed call rejects
343
+ * with a `HostError` whose `code` says why: `denied` for a notebook or
344
+ * method this notebook isn't granted. Branch on `code`: `HostError`
345
+ * isn't a global, so `instanceof` isn't available.
346
+ *
347
+ * @param options `timeout`: how long to wait for a server, in ms.
348
+ * @throws {HostError} `cancelled` when an interrupt ends the wait.
349
+ * @example
350
+ * ```ts
351
+ * const host = await jupyter.host();
352
+ * if (host) {
353
+ * const { hits } = await host.notebooks.search({ query: "weekday drop", dir: "journal" });
354
+ * hits.map((h) => `${h.path}: ${h.excerpt}`);
355
+ * }
356
+ * ```
357
+ * @see jupyter-api.md#other-notebooks-jupyter-host
358
+ */
359
+ host(options?: HostOptions): Promise<Host | null>;
51
360
  }
52
361
 
53
362
  export interface Jupyter {
@@ -15,6 +15,7 @@
15
15
  // Deno doesn't have; see comm.mjs and widget.mjs.
16
16
 
17
17
  import { createComms } from "./comm.mjs";
18
+ import { createHost } from "./host.mjs";
18
19
  import { createWidgetFactory } from "./widget.mjs";
19
20
 
20
21
  export const $display = Symbol.for("Jupyter.display");
@@ -343,8 +344,20 @@ function isMediaBundle(obj) {
343
344
  * readFile: (path: string) => Uint8Array,
344
345
  * send: (msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void,
345
346
  * input: (prompt: string, password: boolean) => string | null,
347
+ * params?: () => string | undefined,
346
348
  * }} host
347
349
  */
350
+ /** The MIME type of a named result (cdp-kernel#456): `{ name, value }`. */
351
+ export const RESULT_MIME = "application/vnd.ganymede.result+json";
352
+
353
+ function deepFreeze(value) {
354
+ if (typeof value === "object" && value !== null) {
355
+ for (const v of Object.values(value)) deepFreeze(v);
356
+ Object.freeze(value);
357
+ }
358
+ return value;
359
+ }
360
+
348
361
  export function createJupyter(host) {
349
362
  async function format(obj) {
350
363
  return await formatNow(obj);
@@ -439,6 +452,46 @@ export function createJupyter(host) {
439
452
  return answer === "" ? defaultValue : answer;
440
453
  }
441
454
 
455
+ // jupyter.params (cdp-kernel#456): the run's parameters, which the host
456
+ // (ganymede execute -p) passes as JSON in GANYMEDE_PARAMS. Parsed on first
457
+ // read, so a bad value fails the cell that reads it, not the kernel.
458
+ let params;
459
+ function readParams() {
460
+ if (params !== undefined) return params;
461
+ const text = host.params?.();
462
+ if (text === undefined || text === "") return (params = Object.freeze({}));
463
+ let value;
464
+ try {
465
+ value = JSON.parse(text);
466
+ } catch (err) {
467
+ throw new TypeError(`GANYMEDE_PARAMS is not JSON: ${err instanceof Error ? err.message : err}`);
468
+ }
469
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
470
+ throw new TypeError("GANYMEDE_PARAMS must be a JSON object, such as {\"counter\": \"Fremont\"}");
471
+ }
472
+ return (params = deepFreeze(value));
473
+ }
474
+
475
+ // jupyter.result (cdp-kernel#456): a named value as an output of
476
+ // Ganymede's result type, which a run's caller reads without parsing
477
+ // outputs. JSON only, so every reader sees the same value.
478
+ function result(name, value) {
479
+ if (typeof name !== "string" || name === "") throw new TypeError("jupyter.result: name must be a non-empty string");
480
+ let text;
481
+ try {
482
+ text = JSON.stringify(value);
483
+ } catch (err) {
484
+ throw new TypeError(`jupyter.result: ${name} is not JSON: ${err instanceof Error ? err.message : err}`);
485
+ }
486
+ if (text === undefined) throw new TypeError(`jupyter.result: ${name} is not JSON (${typeof value})`);
487
+ const json = JSON.parse(text);
488
+ broadcast("display_data", {
489
+ data: { [RESULT_MIME]: { name, value: json }, "text/plain": `${name} = ${text}` },
490
+ metadata: {},
491
+ transient: {},
492
+ });
493
+ }
494
+
442
495
  const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
443
496
 
444
497
  // jupyter.signal (#418): aborted when the kernel is interrupted, so cell
@@ -462,6 +515,11 @@ export function createJupyter(host) {
462
515
  for (const controller of handlers.keys()) controller.abort(reason);
463
516
  handlers.clear();
464
517
  }
518
+ // jupyter.host() (#461): Ganymede's host API. Registers its comm target
519
+ // now, so the comm the server opens at kernel start finds it. A call
520
+ // without a signal watches the jupyter.signal of whatever started it.
521
+ const hostApi = createHost(comms.api, () => current.signal);
522
+
465
523
  async function commDispatch(msgType, content, metadata, buffers, ownSignal = false) {
466
524
  // A handler during a cell shares the cell's signal, and is tracked
467
525
  // apart from it: it can outlive the cell.
@@ -484,6 +542,10 @@ export function createJupyter(host) {
484
542
  },
485
543
  broadcast,
486
544
  display,
545
+ get params() {
546
+ return readParams();
547
+ },
548
+ result,
487
549
  format,
488
550
  md: taggedTemplate("text/markdown"),
489
551
  html: taggedTemplate("text/html"),
@@ -491,6 +553,7 @@ export function createJupyter(host) {
491
553
  image,
492
554
  $display,
493
555
  comm: comms.api,
556
+ host: hostApi.host,
494
557
  widget: createWidgetFactory(comms.api, $display),
495
558
  },
496
559
  broadcastResult,