@tlockney/cdp-kernel 0.11.0 → 0.12.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.
@@ -4,6 +4,17 @@ import { type Channel, type JupyterMessage, type MessageListener } from "./chann
4
4
  import { type BrowserTargetOptions } from "./target.ts";
5
5
  export type { Channel, JupyterMessage, MessageListener } from "./channels.ts";
6
6
  export { BrowserTarget, type BrowserTargetOptions, type HostHandler, type HostRequest, type WorkerLike } from "./target.ts";
7
+ /**
8
+ * The packages `jupyter.chart` imports, as it writes them: map them in
9
+ * `modules` to copies the page serves, and charts draw offline. The page
10
+ * serves ES modules that export what the packages do. Same as
11
+ * runtime/chart.mjs's VEGA, VEGA_LITE and LINKEDOM.
12
+ */
13
+ export declare const CHART_MODULES: {
14
+ readonly vega: "npm:vega@6";
15
+ readonly vegaLite: "npm:vega-lite@6";
16
+ readonly linkedom: "npm:linkedom@0.18";
17
+ };
7
18
  export interface BrowserKernelOptions extends BrowserTargetOptions {
8
19
  /** What strips cells' types; sucrase by default, since esbuild's native binary can't run in a page. */
9
20
  typeStrip?: TypeStripperName | TypeStripper;
@@ -15,6 +15,17 @@ import { VERSION } from "../package.js";
15
15
  import { PageChannels } from "./channels.js";
16
16
  import { BrowserTarget } from "./target.js";
17
17
  export { BrowserTarget } from "./target.js";
18
+ /**
19
+ * The packages `jupyter.chart` imports, as it writes them: map them in
20
+ * `modules` to copies the page serves, and charts draw offline. The page
21
+ * serves ES modules that export what the packages do. Same as
22
+ * runtime/chart.mjs's VEGA, VEGA_LITE and LINKEDOM.
23
+ */
24
+ export const CHART_MODULES = {
25
+ vega: "npm:vega@6",
26
+ vegaLite: "npm:vega-lite@6",
27
+ linkedom: "npm:linkedom@0.18",
28
+ };
18
29
  export function startBrowserKernel(options) {
19
30
  const channels = new PageChannels();
20
31
  const log = options.log ?? ((text) => console.debug(`[cdp-kernel] ${text}`));
@@ -22,6 +22,16 @@ export interface BrowserTargetOptions {
22
22
  base?: string;
23
23
  /** Where npm:, jsr: and bare imports load from; esm.sh by default. */
24
24
  cdn?: string;
25
+ /**
26
+ * Specifiers, as written, mapped to URLs the page serves (resolved
27
+ * against `base`), which win over `cdn`: a cell's imports and the
28
+ * libraries `jupyter.chart` and `jupyter.sql` import load from there.
29
+ * Mapping `CHART_MODULES` makes charts draw offline; `jupyter.sql` still
30
+ * loads DuckDB's worker and .wasm from jsDelivr.
31
+ */
32
+ modules?: {
33
+ readonly [specifier: string]: string;
34
+ };
25
35
  /**
26
36
  * Modules the Worker imports before the first cell, each time it starts,
27
37
  * resolved against `base`. A module's default export, if a function, is
@@ -49,7 +49,7 @@ export class BrowserTarget {
49
49
  const base = options.base ?? globalThis.location?.href ?? "file:///";
50
50
  try {
51
51
  const params = options.params === undefined ? undefined : JSON.stringify(options.params);
52
- const { userAgent } = await target.#request("info", { base, cdn: options.cdn, preload: options.preload ?? [], params });
52
+ const { userAgent } = await target.#request("info", { base, cdn: options.cdn, modules: options.modules, preload: options.preload ?? [], params });
53
53
  if (typeof userAgent === "string")
54
54
  target.versions.runtime = browserName(userAgent);
55
55
  target.versions.engine = engineName(typeof userAgent === "string" ? userAgent : "");
@@ -715,6 +715,17 @@ function textTable(fields, numbered) {
715
715
  for (const row of cells) row.forEach((c, i) => widths[i] = Math.max(widths[i], c.length));
716
716
  return cells.map((row) => ` ${row.map((c, i) => c.padEnd(widths[i])).join(" | ").trimEnd()}`);
717
717
  }
718
+ var PAGEABLE = /* @__PURE__ */ Symbol("cdpk.table.pageable");
719
+ function encodeRow(source, fields, row) {
720
+ return fields.map((f, i) => encodeValue(row[i], f.type, source.columns[i].declared));
721
+ }
722
+ function pageTable(bundle, pager, key2) {
723
+ const resource = bundle?.[TABLE_MIME];
724
+ const pending = resource?.[PAGEABLE];
725
+ if (!pending || !pager || resource.ganymede?.page !== void 0) return bundle;
726
+ resource.ganymede.page = pager.register(pending.source, pending.fields, key2);
727
+ return bundle;
728
+ }
718
729
  function tableBundle(source, options = {}) {
719
730
  const requested = options.limit ?? DEFAULT_LIMIT;
720
731
  if (!Number.isInteger(requested) || requested < 0) throw new TypeError("jupyter.table: limit must be a whole number");
@@ -723,7 +734,7 @@ function tableBundle(source, options = {}) {
723
734
  const total = source.total;
724
735
  const raw = source.read(0, Math.min(limit, total));
725
736
  const fields = fieldsOf(source, raw);
726
- const encode = (r) => fields.map((f, i) => encodeValue(r[i], f.type, source.columns[i].declared));
737
+ const encode = (r) => encodeRow(source, fields, r);
727
738
  const utf8 = new TextEncoder();
728
739
  const encoded = [];
729
740
  const data = [];
@@ -772,6 +783,10 @@ function tableBundle(source, options = {}) {
772
783
  if (text.length > TEXT_MAX) text = `${text.slice(0, TEXT_MAX - 1)}\u2026`;
773
784
  const resource = { schema: { fields }, data, ganymede: { rows: total, shown } };
774
785
  if (title) resource.ganymede.title = title;
786
+ if (shown < total) {
787
+ Object.defineProperty(resource, PAGEABLE, { value: { source, fields } });
788
+ if (options.pager) pageTable({ [TABLE_MIME]: resource }, options.pager);
789
+ }
775
790
  return {
776
791
  [TABLE_MIME]: resource,
777
792
  "text/html": htmlTable(fields, htmlHead, htmlTail, total, title),
@@ -1357,6 +1372,133 @@ function createChart({ importModule, summarizeSpec, warn = (m) => console.warn(m
1357
1372
  return chart;
1358
1373
  }
1359
1374
 
1375
+ // runtime/table-paging.mjs
1376
+ var PAGE_TARGET = "ganymede.table.v1";
1377
+ var KEEP = 16;
1378
+ var MAX_PAGE = 1e3;
1379
+ var MAX_SORT = 1e6;
1380
+ var CHUNK = 1e4;
1381
+ var SORTS = 2;
1382
+ var NUMERIC = /* @__PURE__ */ new Set(["integer", "number"]);
1383
+ var INTEGER_TEXT = /^-?\d+$/;
1384
+ function sortNumber(value) {
1385
+ if (typeof value === "number") return value;
1386
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
1387
+ return Number.NaN;
1388
+ }
1389
+ function textOf(value) {
1390
+ if (value === null || value === void 0) return "null";
1391
+ if (typeof value === "string") return value;
1392
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1393
+ return JSON.stringify(value);
1394
+ }
1395
+ var collator = new Intl.Collator(void 0, { numeric: true });
1396
+ function compareEncoded(a, b, type, descending = false) {
1397
+ const numeric = NUMERIC.has(type) || typeof a === "number" && typeof b === "number";
1398
+ const last = (v) => v === null || v === void 0 || numeric && Number.isNaN(sortNumber(v));
1399
+ const aLast = last(a);
1400
+ const bLast = last(b);
1401
+ if (aLast || bLast) return aLast === bLast ? 0 : aLast ? 1 : -1;
1402
+ const sign = descending ? -1 : 1;
1403
+ if (numeric) {
1404
+ if (typeof a === "string" && typeof b === "string" && INTEGER_TEXT.test(a) && INTEGER_TEXT.test(b)) {
1405
+ const x = BigInt(a);
1406
+ const y = BigInt(b);
1407
+ return sign * (x < y ? -1 : x > y ? 1 : 0);
1408
+ }
1409
+ return sign * (sortNumber(a) - sortNumber(b));
1410
+ }
1411
+ if (typeof a === "boolean" && typeof b === "boolean") return sign * (Number(a) - Number(b));
1412
+ return sign * collator.compare(textOf(a), textOf(b));
1413
+ }
1414
+ function createTablePager(commApi, { keep = KEEP, newId = () => crypto.randomUUID() } = {}) {
1415
+ const tables = /* @__PURE__ */ new Map();
1416
+ const keyed = /* @__PURE__ */ new Map();
1417
+ function register(source, fields, key2) {
1418
+ let id = key2 === void 0 ? void 0 : keyed.get(key2);
1419
+ if (id === void 0 || !tables.has(id)) id = newId();
1420
+ if (key2 !== void 0) keyed.set(key2, id);
1421
+ tables.delete(id);
1422
+ tables.set(id, { source, fields, key: key2, orders: /* @__PURE__ */ new Map() });
1423
+ while (tables.size > keep) {
1424
+ const [oldest, entry] = tables.entries().next().value;
1425
+ tables.delete(oldest);
1426
+ if (entry.key !== void 0 && keyed.get(entry.key) === oldest) keyed.delete(entry.key);
1427
+ }
1428
+ return id;
1429
+ }
1430
+ function encodeRows(entry, raw) {
1431
+ return raw.map((r) => encodeRow(entry.source, entry.fields, r));
1432
+ }
1433
+ function orderFor(entry, column, descending) {
1434
+ const key2 = `${column}\0${descending}`;
1435
+ const kept = entry.orders.get(key2);
1436
+ if (kept) return kept;
1437
+ const { source, fields } = entry;
1438
+ const c = fields.findIndex((f) => f.name === column);
1439
+ if (c < 0) throw new Error(`no column ${JSON.stringify(column)}`);
1440
+ if (source.total > MAX_SORT) throw new Error(`sorting is offered up to ${MAX_SORT.toLocaleString("en-US")} rows`);
1441
+ const type = fields[c].type;
1442
+ const declared = source.columns[c].declared;
1443
+ const values = new Array(source.total);
1444
+ for (let start = 0; start < source.total; start += CHUNK) {
1445
+ const raw = source.read(start, Math.min(start + CHUNK, source.total));
1446
+ raw.forEach((r, i) => values[start + i] = encodeValue(r[c], type, declared));
1447
+ }
1448
+ const index = Array.from({ length: source.total }, (_, i) => i);
1449
+ index.sort((i, j) => compareEncoded(values[i], values[j], type, descending));
1450
+ entry.orders.set(key2, index);
1451
+ while (entry.orders.size > SORTS) entry.orders.delete(entry.orders.keys().next().value);
1452
+ return index;
1453
+ }
1454
+ function page(entry, { offset, limit, sort }) {
1455
+ const { source } = entry;
1456
+ const start = Math.max(0, Math.min(offset, source.total));
1457
+ const end = Math.min(source.total, start + Math.min(limit, MAX_PAGE));
1458
+ if (!sort) return encodeRows(entry, source.read(start, end));
1459
+ const index = orderFor(entry, String(sort.column), sort.descending === true).slice(start, end);
1460
+ const rows = [];
1461
+ for (let i = 0; i < index.length; ) {
1462
+ let j = i + 1;
1463
+ while (j < index.length && index[j] === index[j - 1] + 1) j++;
1464
+ rows.push(...source.read(index[i], index[j - 1] + 1));
1465
+ i = j;
1466
+ }
1467
+ return encodeRows(entry, rows);
1468
+ }
1469
+ const gone = "The kernel no longer has this table: run the cell again to page through it.";
1470
+ commApi.registerTarget(PAGE_TARGET, (comm, msg) => {
1471
+ const id = msg.data?.id;
1472
+ if (typeof id !== "string" || !tables.has(id)) {
1473
+ comm.send({ error: gone });
1474
+ comm.close();
1475
+ return;
1476
+ }
1477
+ comm.onMsg(({ data }) => {
1478
+ const request = data?.request;
1479
+ const entry = tables.get(id);
1480
+ if (!entry) {
1481
+ comm.send({ request, error: gone });
1482
+ comm.close();
1483
+ return;
1484
+ }
1485
+ const offset = data?.offset;
1486
+ const limit = data?.limit ?? MAX_PAGE;
1487
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1) {
1488
+ comm.send({ request, error: "offset and limit must be whole numbers" });
1489
+ return;
1490
+ }
1491
+ try {
1492
+ const rows = page(entry, { offset, limit, sort: data?.sort ?? void 0 });
1493
+ comm.send({ request, offset, total: entry.source.total, rows });
1494
+ } catch (err) {
1495
+ comm.send({ request, error: err instanceof Error ? err.message : String(err) });
1496
+ }
1497
+ });
1498
+ });
1499
+ return { register, size: () => tables.size };
1500
+ }
1501
+
1360
1502
  // runtime/display.mjs
1361
1503
  var $display3 = /* @__PURE__ */ Symbol.for("Jupyter.display");
1362
1504
  var rawToEntity3 = /* @__PURE__ */ new Map([
@@ -1631,10 +1773,11 @@ function createJupyter(host2) {
1631
1773
  function broadcast(msgType, content, { metadata = {}, buffers = [] } = {}) {
1632
1774
  host2.send(msgType, content, metadata, buffers);
1633
1775
  }
1776
+ let pager;
1634
1777
  async function display(obj, options = { raw: false, update: false }) {
1635
1778
  const bundle = options.raw && isMediaBundle(obj) ? obj : await format2(obj);
1636
1779
  broadcast(options.update ? "update_display_data" : "display_data", {
1637
- data: bundle,
1780
+ data: pageTable(bundle, pager, options.display_id),
1638
1781
  metadata: {},
1639
1782
  transient: options.display_id ? { display_id: options.display_id } : {}
1640
1783
  });
@@ -1644,7 +1787,7 @@ function createJupyter(host2) {
1644
1787
  try {
1645
1788
  broadcast("execute_result", {
1646
1789
  execution_count: executionCount,
1647
- data: await format2(result2),
1790
+ data: pageTable(await format2(result2), pager),
1648
1791
  metadata: {},
1649
1792
  // nbclient crashes on a null `transient`; Deno learned this too.
1650
1793
  transient: {}
@@ -1706,6 +1849,7 @@ function createJupyter(host2) {
1706
1849
  const sql = createSql({ importModule: cellImport("jupyter.sql"), signal: () => current.signal });
1707
1850
  const chart = createChart({ importModule: cellImport("jupyter.chart"), summarizeSpec: summarizeChart });
1708
1851
  const comms = createComms((msgType, content, metadata, buffers) => host2.send(msgType, content, metadata, buffers));
1852
+ pager = createTablePager(comms.api);
1709
1853
  let cell = new AbortController();
1710
1854
  let current = cell;
1711
1855
  const handlers = /* @__PURE__ */ new Map();
@@ -2052,7 +2196,8 @@ ${resolved.name} has a getter, which isn't called for inspection: it could have
2052
2196
 
2053
2197
  // runtime/cdn.mjs
2054
2198
  var DEFAULT_CDN = "https://esm.sh/";
2055
- function resolveSpecifier(specifier, base, cdn2 = DEFAULT_CDN) {
2199
+ function resolveSpecifier(specifier, base, cdn2 = DEFAULT_CDN, modules2 = void 0) {
2200
+ if (modules2 && Object.hasOwn(modules2, specifier)) return new URL(modules2[specifier], base).href;
2056
2201
  if (/^(?:https?|data|blob):/.test(specifier)) return specifier;
2057
2202
  if (/^\.{0,2}\//.test(specifier)) return new URL(specifier, base).href;
2058
2203
  if (specifier.startsWith("npm:")) return cdn2 + specifier.slice(4);
@@ -2107,6 +2252,7 @@ var jupyter = createJupyter({
2107
2252
  });
2108
2253
  var paramsText;
2109
2254
  var cdn = DEFAULT_CDN;
2255
+ var modules;
2110
2256
  var hostCalls = /* @__PURE__ */ new Map();
2111
2257
  var hostListeners = /* @__PURE__ */ new Set();
2112
2258
  var nextHostId = 0;
@@ -2173,13 +2319,14 @@ async function preload(urls, base) {
2173
2319
  }
2174
2320
  }
2175
2321
  var handle = null;
2176
- function install({ base, cdn: cdnBase, params }) {
2322
+ function install({ base, cdn: cdnBase, modules: moduleMap, params }) {
2177
2323
  if (typeof params === "string") paramsText = params;
2178
2324
  if (typeof cdnBase === "string") cdn = cdnBase.endsWith("/") ? cdnBase : cdnBase + "/";
2325
+ if (moduleMap !== null && typeof moduleMap === "object") modules = { ...moduleMap };
2179
2326
  const url = new URL("$cdpk$repl.mjs", base).href;
2180
2327
  globalThis.__cdpk = {
2181
- import: (specifier, options) => import(resolveSpecifier(specifier, url, cdn)),
2182
- meta: { url, resolve: (specifier) => resolveSpecifier(specifier, url, cdn) },
2328
+ import: (specifier, options) => import(resolveSpecifier(specifier, url, cdn, modules)),
2329
+ meta: { url, resolve: (specifier) => resolveSpecifier(specifier, url, cdn, modules) },
2183
2330
  // inspect_request (#10): by descriptor, so nothing runs.
2184
2331
  describeForInspect: (value, detail) => describeValue(value, { inspect: (v) => inspect(v, { depth: 2 }), detail }),
2185
2332
  inspectPath: (path, detail) => inspectPath(path, { inspect: (v) => inspect(v, { depth: 2 }), detail }),