@tlockney/cdp-kernel 0.9.0 → 0.11.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,7 +1,10 @@
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";
4
+ import type { Sql, SqlColumn, SqlFragment, SqlResult } from "./sql.d.mts";
3
5
 
4
- export type { CommApi, Widget, WidgetOptions };
6
+ export type { Sql, SqlColumn, SqlFragment, SqlResult };
7
+ export type { CommApi, Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch, Widget, WidgetOptions };
5
8
 
6
9
  /**
7
10
  * The symbol a class implements to choose how its instances appear. At
@@ -23,6 +26,33 @@ export interface DisplayHost {
23
26
  readFile: (path: string) => Uint8Array;
24
27
  send: (msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void;
25
28
  input: (prompt: string, password: boolean) => string | null;
29
+ /** The run's parameters as JSON text, or `undefined` when there are none. */
30
+ params?: () => string | undefined;
31
+ }
32
+
33
+ /** The MIME type of a named result: `{ name, value }`. */
34
+ export const RESULT_MIME: "application/vnd.ganymede.result+json";
35
+
36
+ /** A JSON value, as `JSON.parse` returns it. */
37
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
38
+
39
+ /** Options for `jupyter.table`. */
40
+ export interface TableOptions {
41
+ /** Rows to embed: default 1,000, at most 10,000, and never past 1 MB of JSON. */
42
+ limit?: number;
43
+ /** The columns to show, in order. Names an array of arrays' columns. */
44
+ columns?: string[];
45
+ /** A caption for the table. */
46
+ title?: string;
47
+ }
48
+
49
+ /** Options for `jupyter.chart` with a Vega-Lite or Vega spec. */
50
+ export interface ChartOptions {
51
+ /**
52
+ * Also keep the spec under its Vega MIME type, for a frontend that
53
+ * draws Vega itself. Default `false`: the SVG and a text summary only.
54
+ */
55
+ spec?: boolean;
26
56
  }
27
57
 
28
58
  /** Options for `jupyter.display`. */
@@ -283,6 +313,174 @@ export interface JupyterNamespace {
283
313
  * @see jupyter-api.md#stopping-on-interrupt-jupyter-signal
284
314
  */
285
315
  readonly signal: AbortSignal;
316
+ /**
317
+ * The parameters of this run: a frozen object, empty unless whoever
318
+ * started the kernel passed some, such as `ganymede execute -p
319
+ * counter="2nd Ave"`. They arrive as a JSON object in the kernel's
320
+ * `GANYMEDE_PARAMS` environment variable. Take defaults with
321
+ * destructuring, so the notebook also runs on its own.
322
+ *
323
+ * @throws {TypeError} `GANYMEDE_PARAMS` is set but isn't a JSON object.
324
+ * @availability Node, Deno, Bun, workerd and celld read the kernel's
325
+ * environment; the browser target takes `params` from its options.
326
+ * @example
327
+ * ```ts
328
+ * const { counter = "Fremont", days = 30 } = jupyter.params as { counter?: string; days?: number };
329
+ * `${counter}, last ${days} days`;
330
+ * ```
331
+ * @see jupyter-api.md#parameters-and-named-results
332
+ */
333
+ readonly params: { readonly [name: string]: JsonValue };
334
+ /**
335
+ * Publish a value under a name, as an output that a run's caller reads
336
+ * by name instead of parsing outputs: `ganymede execute --results`, or
337
+ * another notebook. The output has the MIME type
338
+ * `application/vnd.ganymede.result+json` holding `{ name, value }`, and
339
+ * a `text/plain` line `name = value` for frontends that don't know the
340
+ * type. When a name is published twice, readers take the last one.
341
+ *
342
+ * @param name The result's name.
343
+ * @param value Any value `JSON.stringify` accepts; it is stored as JSON,
344
+ * so a `Date` becomes its ISO string.
345
+ * @throws {TypeError} `name` is empty, or `value` isn't JSON (a
346
+ * function, `undefined`, a `BigInt` or a cycle).
347
+ * @example
348
+ * ```ts
349
+ * const counts = [3120, 2890, 2410];
350
+ * jupyter.result("weekday_drop", (counts[2] - counts[0]) / counts[0]);
351
+ * jupyter.result("counts", counts);
352
+ * ```
353
+ * @see jupyter-api.md#parameters-and-named-results
354
+ */
355
+ result(name: string, value: unknown): void;
356
+ /**
357
+ * Show rows and columns as a table. The output has Ganymede's table
358
+ * type, `application/vnd.dataresource+json` (Table Schema fields, up to
359
+ * `limit` rows as JSON, and the total row count), which the Ganymede
360
+ * apps show with sorting and paging; a plain HTML table of the first
361
+ * and last rows for JupyterLab and VS Code; and a text summary of each
362
+ * column's type and range for agents and consoles.
363
+ *
364
+ * An Arrow table, a Polars DataFrame or a DuckDB result also shows this
365
+ * way when it is a cell's value; pass one here to set `limit`,
366
+ * `columns` or `title`.
367
+ *
368
+ * @param value An array of row objects, an array of arrays (name them
369
+ * with `columns`), an Arrow table (apache-arrow, duckdb-wasm), a
370
+ * Polars DataFrame, or a `@duckdb/node-api` result.
371
+ * @param options `limit`: rows to embed, default 1,000, at most 10,000,
372
+ * and never more than 1 MB of JSON. `columns`: which columns, in order.
373
+ * `title`: a caption.
374
+ * @throws {TypeError} `value` isn't one of those, or `limit` isn't a
375
+ * whole number.
376
+ * @example
377
+ * ```ts
378
+ * const moons = [
379
+ * { moon: "Io", radius_km: 1821.6, discovered: new Date("1610-01-08") },
380
+ * { moon: "Europa", radius_km: 1560.8, discovered: new Date("1610-01-08") },
381
+ * { moon: "Ganymede", radius_km: 2634.1, discovered: new Date("1610-01-07") },
382
+ * ];
383
+ * jupyter.table(moons, { title: "Galilean moons" });
384
+ * ```
385
+ * @see jupyter-api.md#tables
386
+ */
387
+ table(value: unknown, options?: TableOptions): Displayable;
388
+ /**
389
+ * Run SQL on an in-memory [DuckDB](https://duckdb.org) database, as a
390
+ * tagged template: each `${value}` becomes a bound parameter, never
391
+ * query text, so values can't change the query. Resolves to a
392
+ * `SqlResult`, which shows as a table and has `columns` and `rows`.
393
+ *
394
+ * The database opens on the first query and lasts until the kernel
395
+ * restarts or `jupyter.sql.close()`. DuckDB reads CSV, Parquet and JSON
396
+ * files by path or URL; `jupyter.sql.load` makes a table from rows the
397
+ * notebook already has. An interrupt stops a running query.
398
+ *
399
+ * @throws {TypeError} Called as a function rather than a tag.
400
+ * @availability Node, Deno and Bun use `@duckdb/node-api`; the browser
401
+ * target (the Ganymede apps) uses duckdb-wasm from a CDN. Not on
402
+ * workerd or celld.
403
+ * @example
404
+ * ```ts
405
+ * const min = 2000;
406
+ * await jupyter.sql`
407
+ * select moon, radius_km
408
+ * from (values ('Io', 1821.6), ('Ganymede', 2634.1), ('Callisto', 2410.3)) as m(moon, radius_km)
409
+ * where radius_km > ${min}
410
+ * order by radius_km desc`;
411
+ * ```
412
+ * @see jupyter-api.md#sql-with-duckdb
413
+ */
414
+ readonly sql: Sql;
415
+ /**
416
+ * Draw a chart in the kernel as one SVG, with its title and legend
417
+ * inside, plus a text summary for readers that can't see it. The SVG
418
+ * shows the same in the Ganymede apps, JupyterLab, VS Code and a
419
+ * published page. Text and axes are drawn in `currentColor`: the
420
+ * notebook's text color in the Ganymede apps, black in JupyterLab.
421
+ *
422
+ * Pass a [Vega-Lite](https://vega.github.io/vega-lite/) or Vega spec
423
+ * (or a vega-lite-api chart), or the
424
+ * [Observable Plot](https://observablehq.com/plot/) module and the
425
+ * options you would give `Plot.plot`. Plot's `title`, `subtitle`,
426
+ * `caption` and the color legend (`legend: true`, `"swatches"` or
427
+ * `"ramp"`) are drawn into the SVG; other scales' legends are left
428
+ * out. An SVG element shows as it is.
429
+ *
430
+ * vega and vega-lite (version 6), and linkedom for Plot where there is
431
+ * no DOM, load through the cell's import on first use. A chart past
432
+ * 2 MB of SVG logs a warning: aggregate or sample first.
433
+ *
434
+ * @param chart A Vega-Lite or Vega spec, the Plot module, or an SVG
435
+ * element.
436
+ * @param options With a spec, `ChartOptions`; with Plot, Plot's options.
437
+ * @throws {TypeError} `chart` is none of those, or Plot comes without
438
+ * options.
439
+ * @example
440
+ * ```ts
441
+ * jupyter.chart({
442
+ * title: "Galilean moons",
443
+ * data: { values: [
444
+ * { moon: "Io", radius_km: 1821.6 },
445
+ * { moon: "Europa", radius_km: 1560.8 },
446
+ * { moon: "Ganymede", radius_km: 2634.1 },
447
+ * { moon: "Callisto", radius_km: 2410.3 },
448
+ * ] },
449
+ * mark: "bar",
450
+ * encoding: {
451
+ * x: { field: "moon", sort: "-y" },
452
+ * y: { field: "radius_km", type: "quantitative" },
453
+ * },
454
+ * });
455
+ * ```
456
+ * @see jupyter-api.md#charts
457
+ */
458
+ chart(chart: object, options?: ChartOptions | Record<string, unknown>): Displayable;
459
+ /**
460
+ * The Ganymede server running this kernel, through its host API: read,
461
+ * search and (when granted) run other notebooks under its root. The
462
+ * server opens a comm to the kernel when it starts; this resolves to
463
+ * `null` when none answers, as under plain Jupyter.
464
+ *
465
+ * Each call returns a promise and takes `{ signal }`, by default
466
+ * `jupyter.signal`, so an interrupt cancels it. A failed call rejects
467
+ * with a `HostError` whose `code` says why: `denied` for a notebook or
468
+ * method this notebook isn't granted. Branch on `code`: `HostError`
469
+ * isn't a global, so `instanceof` isn't available.
470
+ *
471
+ * @param options `timeout`: how long to wait for a server, in ms.
472
+ * @throws {HostError} `cancelled` when an interrupt ends the wait.
473
+ * @example
474
+ * ```ts
475
+ * const host = await jupyter.host();
476
+ * if (host) {
477
+ * const { hits } = await host.notebooks.search({ query: "weekday drop", dir: "journal" });
478
+ * hits.map((h) => `${h.path}: ${h.excerpt}`);
479
+ * }
480
+ * ```
481
+ * @see jupyter-api.md#other-notebooks-jupyter-host
482
+ */
483
+ host(options?: HostOptions): Promise<Host | null>;
286
484
  }
287
485
 
288
486
  export interface Jupyter {
@@ -4,18 +4,26 @@
4
4
  //
5
5
  // Ported from Deno's cli/js/40_jupyter.js (v2.9.6), which is
6
6
  // Copyright 2018-2026 the Deno authors, MIT license. The formatting rules
7
- // (display symbol, canvas, Vega, Polars-style DataFrames, JPEG/PNG bytes,
8
- // HTML/SVG elements) are unchanged; the Deno internals it used are injected:
7
+ // (display symbol, canvas, Vega, JPEG/PNG bytes, HTML/SVG elements) are
8
+ // unchanged; Polars-style DataFrames show through table.mjs instead (#475),
9
+ // as do Arrow tables and DuckDB results. The Deno internals it used are
10
+ // injected:
9
11
  // Deno[Deno.internal].inspectArgs -> host.inspect
10
12
  // op_base64_encode_from_buffer -> host.base64
11
13
  // Deno.readFileSync -> host.readFile
12
14
  // op_jupyter_broadcast -> host.send
13
15
  // WebGPU texture/buffer display depends on Deno's wgpu bindings and is
14
- // omitted. Comms and widgets (jupyter.comm, jupyter.widget) are additions
15
- // Deno doesn't have; see comm.mjs and widget.mjs.
16
+ // omitted. Comms, widgets, tables, SQL and charts (jupyter.comm,
17
+ // jupyter.widget, jupyter.table, jupyter.sql, jupyter.chart) are additions
18
+ // Deno doesn't have; see comm.mjs, widget.mjs, table.mjs, sql.mjs and
19
+ // chart.mjs.
16
20
 
17
21
  import { createComms } from "./comm.mjs";
22
+ import { createHost } from "./host.mjs";
18
23
  import { createWidgetFactory } from "./widget.mjs";
24
+ import { createSql } from "./sql.mjs";
25
+ import { createChart } from "./chart.mjs";
26
+ import { isTableLike, tableBundle, tableSource } from "./table.mjs";
19
27
 
20
28
  export const $display = Symbol.for("Jupyter.display");
21
29
 
@@ -242,66 +250,6 @@ function summarizeChart(spec) {
242
250
  }
243
251
  }
244
252
 
245
- function isDataFrameLike(obj) {
246
- return obj !== null && typeof obj === "object" &&
247
- obj.schema !== undefined && typeof obj.schema === "object" &&
248
- typeof obj.head === "function" && typeof obj.toRecords === "function";
249
- }
250
-
251
- function mapPolarsTypeToJSONSchema(colType) {
252
- const typeMapping = {
253
- Null: "null",
254
- Bool: "boolean",
255
- Int8: "integer",
256
- Int16: "integer",
257
- Int32: "integer",
258
- Int64: "integer",
259
- UInt8: "integer",
260
- UInt16: "integer",
261
- UInt32: "integer",
262
- UInt64: "integer",
263
- Float32: "number",
264
- Float64: "number",
265
- Date: "string",
266
- Datetime: "string",
267
- Utf8: "string",
268
- Categorical: "string",
269
- List: "array",
270
- Struct: "object",
271
- };
272
- // Polars DataTypes only expose their name through toJSON().
273
- const dataType = typeof colType?.toJSON === "function" ? colType.toJSON()["DataType"] : undefined;
274
- return typeMapping[dataType] || "string";
275
- }
276
-
277
- function extractDataFrame(df) {
278
- const fields = [];
279
- for (const [name, colType] of Object.entries(df.schema)) {
280
- fields.push({ name, type: mapPolarsTypeToJSONSchema(colType) });
281
- }
282
- const data = df.head(50).toRecords();
283
- let html = "<table><thead><tr>";
284
- for (const field of fields) html += `<th>${escapeHTML(String(field.name))}</th>`;
285
- html += "</tr></thead><tbody>";
286
- for (const row of df.head(10).toRecords()) {
287
- html += "<tr>";
288
- for (const field of fields) html += `<td>${escapeHTML(String(row[field.name]))}</td>`;
289
- html += "</tr>";
290
- }
291
- html += "</tbody></table>";
292
- // Polars has `height`; the row count is left out when there isn't one.
293
- const size = [];
294
- if (Number.isInteger(df.height)) size.push(`${df.height} ${df.height === 1 ? "row" : "rows"}`);
295
- size.push(`${fields.length} ${fields.length === 1 ? "column" : "columns"}`);
296
- const columns = fields.slice(0, 20).map((field) => `${clip(String(field.name), 40)} (${field.type})`);
297
- if (fields.length > 20) columns.push(`… ${fields.length - 20} more`);
298
- return {
299
- "application/vnd.dataresource+json": { data, schema: { fields } },
300
- "text/html": html,
301
- "text/plain": `DataFrame (${size.join(", ")})${columns.length ? `: ${columns.join(", ")}` : ""}`,
302
- };
303
- }
304
-
305
253
  function isCanvasLike(obj) {
306
254
  return obj !== null && typeof obj === "object" && "toDataURL" in obj;
307
255
  }
@@ -343,8 +291,20 @@ function isMediaBundle(obj) {
343
291
  * readFile: (path: string) => Uint8Array,
344
292
  * send: (msgType: string, content: object, metadata: object, buffers: Uint8Array[]) => void,
345
293
  * input: (prompt: string, password: boolean) => string | null,
294
+ * params?: () => string | undefined,
346
295
  * }} host
347
296
  */
297
+ /** The MIME type of a named result (cdp-kernel#456): `{ name, value }`. */
298
+ export const RESULT_MIME = "application/vnd.ganymede.result+json";
299
+
300
+ function deepFreeze(value) {
301
+ if (typeof value === "object" && value !== null) {
302
+ for (const v of Object.values(value)) deepFreeze(v);
303
+ Object.freeze(value);
304
+ }
305
+ return value;
306
+ }
307
+
348
308
  export function createJupyter(host) {
349
309
  async function format(obj) {
350
310
  return await formatNow(obj);
@@ -364,7 +324,8 @@ export function createJupyter(host) {
364
324
  const vega = extractVega(obj);
365
325
  if (vega) return vega;
366
326
  }
367
- if (isDataFrameLike(obj)) return extractDataFrame(obj);
327
+ // Arrow tables, Polars DataFrames and DuckDB results (#475).
328
+ if (isTableLike(obj)) return tableBundle(tableSource(obj));
368
329
  if (isJpg(obj)) return { "image/jpeg": host.base64(obj) };
369
330
  if (isPng(obj)) return { "image/png": host.base64(obj) };
370
331
  if (isSVGElementLike(obj)) return { "image/svg+xml": obj.outerHTML };
@@ -396,6 +357,13 @@ export function createJupyter(host) {
396
357
  );
397
358
  }
398
359
 
360
+ // jupyter.table (#475): rows and columns as Ganymede's table output,
361
+ // built now so a bad value fails the call, not the display.
362
+ function table(value, options = {}) {
363
+ const bundle = tableBundle(tableSource(value, options), options);
364
+ return { [$display]: () => bundle };
365
+ }
366
+
399
367
  function broadcast(msgType, content, { metadata = {}, buffers = [] } = {}) {
400
368
  host.send(msgType, content, metadata, buffers);
401
369
  }
@@ -439,6 +407,61 @@ export function createJupyter(host) {
439
407
  return answer === "" ? defaultValue : answer;
440
408
  }
441
409
 
410
+ // jupyter.params (cdp-kernel#456): the run's parameters, which the host
411
+ // (ganymede execute -p) passes as JSON in GANYMEDE_PARAMS. Parsed on first
412
+ // read, so a bad value fails the cell that reads it, not the kernel.
413
+ let params;
414
+ function readParams() {
415
+ if (params !== undefined) return params;
416
+ const text = host.params?.();
417
+ if (text === undefined || text === "") return (params = Object.freeze({}));
418
+ let value;
419
+ try {
420
+ value = JSON.parse(text);
421
+ } catch (err) {
422
+ throw new TypeError(`GANYMEDE_PARAMS is not JSON: ${err instanceof Error ? err.message : err}`);
423
+ }
424
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
425
+ throw new TypeError("GANYMEDE_PARAMS must be a JSON object, such as {\"counter\": \"Fremont\"}");
426
+ }
427
+ return (params = deepFreeze(value));
428
+ }
429
+
430
+ // jupyter.result (cdp-kernel#456): a named value as an output of
431
+ // Ganymede's result type, which a run's caller reads without parsing
432
+ // outputs. JSON only, so every reader sees the same value.
433
+ function result(name, value) {
434
+ if (typeof name !== "string" || name === "") throw new TypeError("jupyter.result: name must be a non-empty string");
435
+ let text;
436
+ try {
437
+ text = JSON.stringify(value);
438
+ } catch (err) {
439
+ throw new TypeError(`jupyter.result: ${name} is not JSON: ${err instanceof Error ? err.message : err}`);
440
+ }
441
+ if (text === undefined) throw new TypeError(`jupyter.result: ${name} is not JSON (${typeof value})`);
442
+ const json = JSON.parse(text);
443
+ broadcast("display_data", {
444
+ data: { [RESULT_MIME]: { name, value: json }, "text/plain": `${name} = ${text}` },
445
+ metadata: {},
446
+ transient: {},
447
+ });
448
+ }
449
+
450
+ // The cell's own import, so npm: resolution and the browser target's CDN
451
+ // apply to the packages jupyter.sql and jupyter.chart load.
452
+ const cellImport = (what) => (specifier) => {
453
+ const load = globalThis.__cdpk?.import;
454
+ if (typeof load !== "function") return Promise.reject(new Error(`${what}: no module loader in this runtime`));
455
+ return load(specifier);
456
+ };
457
+
458
+ // jupyter.sql (#477): DuckDB, loaded on the first query; an interrupt
459
+ // (jupyter.signal) interrupts the query.
460
+ const sql = createSql({ importModule: cellImport("jupyter.sql"), signal: () => current.signal });
461
+
462
+ // jupyter.chart (#479): vega, vega-lite or linkedom, loaded on first use.
463
+ const chart = createChart({ importModule: cellImport("jupyter.chart"), summarizeSpec: summarizeChart });
464
+
442
465
  const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
443
466
 
444
467
  // jupyter.signal (#418): aborted when the kernel is interrupted, so cell
@@ -462,6 +485,11 @@ export function createJupyter(host) {
462
485
  for (const controller of handlers.keys()) controller.abort(reason);
463
486
  handlers.clear();
464
487
  }
488
+ // jupyter.host() (#461): Ganymede's host API. Registers its comm target
489
+ // now, so the comm the server opens at kernel start finds it. A call
490
+ // without a signal watches the jupyter.signal of whatever started it.
491
+ const hostApi = createHost(comms.api, () => current.signal);
492
+
465
493
  async function commDispatch(msgType, content, metadata, buffers, ownSignal = false) {
466
494
  // A handler during a cell shares the cell's signal, and is tracked
467
495
  // apart from it: it can outlive the cell.
@@ -484,6 +512,13 @@ export function createJupyter(host) {
484
512
  },
485
513
  broadcast,
486
514
  display,
515
+ get params() {
516
+ return readParams();
517
+ },
518
+ result,
519
+ table,
520
+ sql,
521
+ chart,
487
522
  format,
488
523
  md: taggedTemplate("text/markdown"),
489
524
  html: taggedTemplate("text/html"),
@@ -491,6 +526,7 @@ export function createJupyter(host) {
491
526
  image,
492
527
  $display,
493
528
  comm: comms.api,
529
+ host: hostApi.host,
494
530
  widget: createWidgetFactory(comms.api, $display),
495
531
  },
496
532
  broadcastResult,