@tlockney/cdp-kernel 0.7.0 → 0.7.1

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,13 +1,16 @@
1
+ import type { JsonObject } from "../message.ts";
1
2
  import type { TypeStripper, TypeStripperName } from "../transform.ts";
2
3
  import { type Channel, type JupyterMessage, type MessageListener } from "./channels.ts";
3
4
  import { type BrowserTargetOptions } from "./target.ts";
4
5
  export type { Channel, JupyterMessage, MessageListener } from "./channels.ts";
5
- export { BrowserTarget, type BrowserTargetOptions, type WorkerLike } from "./target.ts";
6
+ export { BrowserTarget, type BrowserTargetOptions, type HostHandler, type HostRequest, type WorkerLike } from "./target.ts";
6
7
  export interface BrowserKernelOptions extends BrowserTargetOptions {
7
8
  /** What strips cells' types; sucrase by default, since esbuild's native binary can't run in a page. */
8
9
  typeStrip?: TypeStripperName | TypeStripper;
9
10
  /** Where the kernel's own log lines go; console.debug by default. */
10
11
  log?(text: string): void;
12
+ /** Returned as `capabilities` in kernel_info_reply: what this page offers cells (#384). */
13
+ capabilities?: JsonObject;
11
14
  }
12
15
  export interface BrowserKernel {
13
16
  /** A message from the frontend, on shell, control or stdin. */
@@ -16,5 +19,7 @@ export interface BrowserKernel {
16
19
  onMessage(listener: MessageListener): () => void;
17
20
  /** Settles once a shutdown_request has been answered. */
18
21
  readonly closed: Promise<void>;
22
+ /** An event for the Worker's `host.onEvent` listeners; dropped while no Worker runs (#384). */
23
+ notifyHost(event: string, value?: unknown): void;
19
24
  }
20
25
  export declare function startBrowserKernel(options: BrowserKernelOptions): BrowserKernel;
@@ -20,12 +20,14 @@ export function startBrowserKernel(options) {
20
20
  const log = options.log ?? ((text) => console.debug(`[cdp-kernel] ${text}`));
21
21
  let closed;
22
22
  const closedPromise = new Promise((resolve) => (closed = resolve));
23
+ let target = null;
23
24
  const core = new KernelCore({
24
25
  runtime: "browser",
25
26
  version: VERSION,
26
27
  channels,
27
- launchTarget: (events) => BrowserTarget.launch(events, options),
28
+ launchTarget: async (events) => (target = await BrowserTarget.launch(events, options)),
28
29
  typeStrip: options.typeStrip ?? "sucrase",
30
+ capabilities: options.capabilities,
29
31
  log,
30
32
  exit: () => closed(),
31
33
  });
@@ -34,5 +36,6 @@ export function startBrowserKernel(options) {
34
36
  send: (channel, msg) => channels.deliver(channel, msg),
35
37
  onMessage: (listener) => channels.onMessage(listener),
36
38
  closed: closedPromise,
39
+ notifyHost: (event, value) => target?.notifyHost(event, value),
37
40
  };
38
41
  }
@@ -22,7 +22,27 @@ 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
+ * Modules the Worker imports before the first cell, each time it starts,
27
+ * resolved against `base`. A module's default export, if a function, is
28
+ * called with `{ host, jupyter }` (#384).
29
+ */
30
+ preload?: string[];
31
+ /** Answers the Worker's `host.request` calls; without it they reject (#384). */
32
+ host?: HostHandler;
33
+ }
34
+ /** A request a preload module made with `host.request(method, params)`. */
35
+ export interface HostRequest {
36
+ method: string;
37
+ params: unknown;
25
38
  }
39
+ /**
40
+ * Resolves with the value `host.request` resolves with in the Worker, or
41
+ * rejects, and the Worker's promise rejects with an Error carrying the
42
+ * thrown error's `name`, `message` and `code`. `signal` aborts when the
43
+ * Worker cancels the call or stops.
44
+ */
45
+ export type HostHandler = (request: HostRequest, signal: AbortSignal) => unknown;
26
46
  export declare class BrowserTarget implements KernelTarget {
27
47
  #private;
28
48
  readonly runtime: "browser";
@@ -33,6 +53,8 @@ export declare class BrowserTarget implements KernelTarget {
33
53
  readonly transform: typeof transformCellForEval;
34
54
  private constructor();
35
55
  static launch(events: TargetEvents, options: BrowserTargetOptions): Promise<BrowserTarget>;
56
+ /** Sends `host.onEvent` listeners in the Worker an event (#384). */
57
+ notifyHost(event: string, value?: unknown): void;
36
58
  evaluate(script: string): Promise<EvaluateResult>;
37
59
  /**
38
60
  * A cell that's waiting on something leaves the Worker free: it answers a
@@ -53,6 +75,7 @@ export declare class BrowserTarget implements KernelTarget {
53
75
  stack: string;
54
76
  };
55
77
  }>;
78
+ format(value: RemoteObject): Promise<CdpParams>;
56
79
  describeError(exception: RemoteObject): Promise<{
57
80
  name: string;
58
81
  message: string;
@@ -5,7 +5,7 @@
5
5
  // follows. Cells are transformed as for Bun and workerd
6
6
  // (transformCellForEval).
7
7
  import { asEvaluateResult, isRecord } from "../cdp.js";
8
- import { Interrupted, isTargetMessage } from "../target-api.js";
8
+ import { Interrupted, isTargetMessage, parseBundle } from "../target-api.js";
9
9
  import { transformCellForEval } from "../transform.js";
10
10
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
11
11
  export const INTERRUPT_GRACE_MS = 500;
@@ -23,13 +23,17 @@ export class BrowserTarget {
23
23
  #pending = new Map();
24
24
  /** Requests that interrupt() abandons when the driver can still answer. */
25
25
  #interruptible = new Set();
26
+ /** Host requests in flight, by the Worker's id (#384). */
27
+ #hostCalls = new Map();
28
+ #host;
26
29
  #nextId = 0;
27
30
  #interrupted = false;
28
31
  #stopNote;
29
32
  #exited = false;
30
- constructor(worker, events) {
33
+ constructor(worker, events, host) {
31
34
  this.#worker = worker;
32
35
  this.#events = events;
36
+ this.#host = host;
33
37
  worker.addEventListener("message", (event) => this.#receive(event.data));
34
38
  // A driver that failed to load: what's pending can't be answered.
35
39
  worker.addEventListener("error", (event) => {
@@ -38,10 +42,10 @@ export class BrowserTarget {
38
42
  });
39
43
  }
40
44
  static async launch(events, options) {
41
- const target = new BrowserTarget(options.createWorker(), events);
45
+ const target = new BrowserTarget(options.createWorker(), events, options.host);
42
46
  const base = options.base ?? globalThis.location?.href ?? "file:///";
43
47
  try {
44
- const { userAgent } = await target.#request("info", { base, cdn: options.cdn });
48
+ const { userAgent } = await target.#request("info", { base, cdn: options.cdn, preload: options.preload ?? [] });
45
49
  if (typeof userAgent === "string")
46
50
  target.versions.runtime = browserName(userAgent);
47
51
  target.versions.engine = engineName(typeof userAgent === "string" ? userAgent : "");
@@ -56,6 +60,9 @@ export class BrowserTarget {
56
60
  if (isRecord(msg) && msg.kind === "stream") {
57
61
  this.#events.stream(msg.name === "stderr" ? "stderr" : "stdout", String(msg.text));
58
62
  }
63
+ else if (isRecord(msg) && msg.kind === "host") {
64
+ this.#hostRequest(msg);
65
+ }
59
66
  else if (isTargetMessage(msg)) {
60
67
  this.#events.message(msg);
61
68
  }
@@ -72,11 +79,47 @@ export class BrowserTarget {
72
79
  pending.resolve(msg);
73
80
  }
74
81
  }
82
+ #hostRequest(msg) {
83
+ const id = Number(msg.id);
84
+ if (msg.cancel === true) {
85
+ this.#hostCalls.get(id)?.abort();
86
+ this.#hostCalls.delete(id);
87
+ return;
88
+ }
89
+ const controller = new AbortController();
90
+ this.#hostCalls.set(id, controller);
91
+ const answer = (reply) => {
92
+ if (this.#hostCalls.get(id) !== controller)
93
+ return; // cancelled, or the Worker stopped
94
+ this.#hostCalls.delete(id);
95
+ try {
96
+ this.#worker.postMessage({ op: "host", id, ...reply });
97
+ }
98
+ catch (err) {
99
+ // A result structured clone can't copy, such as a function.
100
+ this.#worker.postMessage({ op: "host", id, error: describeHostError(err) });
101
+ }
102
+ };
103
+ const host = this.#host;
104
+ new Promise((resolve) => {
105
+ if (!host)
106
+ throw new Error("this page offers no host requests");
107
+ resolve(host({ method: String(msg.method), params: msg.params }, controller.signal));
108
+ }).then((result) => answer({ result }), (err) => answer({ error: describeHostError(err) }));
109
+ }
110
+ /** Sends `host.onEvent` listeners in the Worker an event (#384). */
111
+ notifyHost(event, value) {
112
+ if (!this.#exited)
113
+ this.#worker.postMessage({ op: "host", event, value });
114
+ }
75
115
  #exit(code) {
76
116
  if (this.#exited)
77
117
  return;
78
118
  this.#exited = true;
79
119
  this.#worker.terminate();
120
+ for (const controller of this.#hostCalls.values())
121
+ controller.abort();
122
+ this.#hostCalls.clear();
80
123
  // Reported before the requests it cut off fail, so the note lands in
81
124
  // the interrupted cell's output.
82
125
  this.#events.exit(code, this.#stopNote);
@@ -150,6 +193,10 @@ export class BrowserTarget {
150
193
  }
151
194
  return { handled: reply.handled === true };
152
195
  }
196
+ async format(value) {
197
+ const reply = await this.#request("format", { handle: value.objectId });
198
+ return parseBundle(reply.value);
199
+ }
153
200
  async describeError(exception) {
154
201
  if (exception.objectId === undefined)
155
202
  return { name: "Error", message: exception.description ?? "", stack: "" };
@@ -175,6 +222,17 @@ export class BrowserTarget {
175
222
  this.#exit(null);
176
223
  }
177
224
  }
225
+ function describeHostError(err) {
226
+ if (isRecord(err)) {
227
+ const { name, message, code } = err;
228
+ return {
229
+ name: typeof name === "string" ? name : "Error",
230
+ message: typeof message === "string" ? message : String(err),
231
+ ...(code === undefined ? {} : { code: code }),
232
+ };
233
+ }
234
+ return { name: "Error", message: String(err) };
235
+ }
178
236
  function toBase64(bytes) {
179
237
  let binary = "";
180
238
  for (let i = 0; i < bytes.length; i += 0x8000)
@@ -524,13 +524,13 @@ function hasDisplaySymbol(obj) {
524
524
  function isMediaBundle(obj) {
525
525
  return obj !== null && typeof obj === "object" && !Array.isArray(obj);
526
526
  }
527
- function createJupyter(host) {
527
+ function createJupyter(host2) {
528
528
  async function format2(obj) {
529
529
  return await formatNow(obj);
530
530
  }
531
531
  function formatNow(obj) {
532
532
  if (hasDisplaySymbol(obj)) return obj[$display]();
533
- if (typeof obj !== "object" || obj === null) return { "text/plain": host.inspect(obj) };
533
+ if (typeof obj !== "object" || obj === null) return { "text/plain": host2.inspect(obj) };
534
534
  if (isCanvasLike(obj)) {
535
535
  const [prefix, data] = obj.toDataURL().split(",");
536
536
  return { [prefix.split(":")[1].split(";")[0]]: data };
@@ -540,11 +540,11 @@ function createJupyter(host) {
540
540
  if (vega) return vega;
541
541
  }
542
542
  if (isDataFrameLike(obj)) return extractDataFrame(obj);
543
- if (isJpg(obj)) return { "image/jpeg": host.base64(obj) };
544
- if (isPng(obj)) return { "image/png": host.base64(obj) };
543
+ if (isJpg(obj)) return { "image/jpeg": host2.base64(obj) };
544
+ if (isPng(obj)) return { "image/png": host2.base64(obj) };
545
545
  if (isSVGElementLike(obj)) return { "image/svg+xml": obj.outerHTML };
546
546
  if (isHTMLElementLike(obj)) return { "text/html": obj.outerHTML };
547
- return { "text/plain": host.inspect(obj) };
547
+ return { "text/plain": host2.inspect(obj) };
548
548
  }
549
549
  function taggedTemplate(mediatype) {
550
550
  return (strings, ...values) => {
@@ -555,18 +555,18 @@ function createJupyter(host) {
555
555
  function image(obj) {
556
556
  if (typeof obj === "string") {
557
557
  try {
558
- obj = host.readFile(obj);
558
+ obj = host2.readFile(obj);
559
559
  } catch {
560
560
  }
561
561
  }
562
- if (isJpg(obj)) return { [$display]: () => ({ "image/jpeg": host.base64(obj) }) };
563
- if (isPng(obj)) return { [$display]: () => ({ "image/png": host.base64(obj) }) };
562
+ if (isJpg(obj)) return { [$display]: () => ({ "image/jpeg": host2.base64(obj) }) };
563
+ if (isPng(obj)) return { [$display]: () => ({ "image/png": host2.base64(obj) }) };
564
564
  throw new TypeError(
565
565
  "Object is not a valid image or a path to an image. `jupyter.image` supports displaying JPG or PNG images."
566
566
  );
567
567
  }
568
568
  function broadcast(msgType, content, { metadata = {}, buffers = [] } = {}) {
569
- host.send(msgType, content, metadata, buffers);
569
+ host2.send(msgType, content, metadata, buffers);
570
570
  }
571
571
  async function display(obj, options = { raw: false, update: false }) {
572
572
  const bundle = options.raw && isMediaBundle(obj) ? obj : await format2(obj);
@@ -595,15 +595,15 @@ function createJupyter(host) {
595
595
  }
596
596
  }
597
597
  function confirm(message = "Confirm") {
598
- const answer2 = host.input(`${message} [y/N] `, false);
598
+ const answer2 = host2.input(`${message} [y/N] `, false);
599
599
  return answer2 === "Y" || answer2 === "y";
600
600
  }
601
601
  function prompt(message = "Prompt", defaultValue = "", { password = false } = {}) {
602
602
  if (defaultValue != "") message += ` [${defaultValue}]`;
603
- const answer2 = host.input(message, password);
603
+ const answer2 = host2.input(message, password);
604
604
  return answer2 === "" ? defaultValue : answer2;
605
605
  }
606
- const comms = createComms((msgType, content, metadata, buffers) => host.send(msgType, content, metadata, buffers));
606
+ const comms = createComms((msgType, content, metadata, buffers) => host2.send(msgType, content, metadata, buffers));
607
607
  return {
608
608
  namespace: {
609
609
  broadcast,
@@ -665,6 +665,8 @@ function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
665
665
  case "broadcast":
666
666
  await shim.broadcastResult(request.count, take(request.handle));
667
667
  return {};
668
+ case "format":
669
+ return { value: await shim.formatJson(request.handle === void 0 ? void 0 : take(request.handle)) };
668
670
  case "describe":
669
671
  return { value: shim.describeError(take(request.handle)) };
670
672
  case "comm":
@@ -952,6 +954,71 @@ var jupyter = createJupyter({
952
954
  input: () => null
953
955
  });
954
956
  var cdn = DEFAULT_CDN;
957
+ var hostCalls = /* @__PURE__ */ new Map();
958
+ var hostListeners = /* @__PURE__ */ new Set();
959
+ var nextHostId = 0;
960
+ function hostError(error) {
961
+ const err = new Error(String(error?.message ?? "the host request failed"));
962
+ if (typeof error?.name === "string") err.name = error.name;
963
+ if (error?.code !== void 0) err.code = error.code;
964
+ return err;
965
+ }
966
+ var host = {
967
+ /** Resolves with the page's answer; aborting `signal` cancels the call on the page too. */
968
+ request(method, params, { signal, transfer } = {}) {
969
+ if (signal?.aborted) return Promise.reject(signal.reason);
970
+ const id = ++nextHostId;
971
+ return new Promise((resolve, reject) => {
972
+ const onAbort = () => {
973
+ hostCalls.delete(id);
974
+ post({ kind: "host", id, cancel: true });
975
+ reject(signal.reason);
976
+ };
977
+ signal?.addEventListener("abort", onAbort, { once: true });
978
+ const done = () => signal?.removeEventListener("abort", onAbort);
979
+ try {
980
+ globalThis.postMessage({ kind: "host", id, method, params }, transfer ?? []);
981
+ } catch (err) {
982
+ done();
983
+ throw err;
984
+ }
985
+ hostCalls.set(id, { resolve, reject, done });
986
+ });
987
+ },
988
+ /** Calls `listener(event, value)` for each event the page sends; returns a function that stops it. */
989
+ onEvent(listener) {
990
+ hostListeners.add(listener);
991
+ return () => hostListeners.delete(listener);
992
+ }
993
+ };
994
+ function receiveHost(msg) {
995
+ if (msg.id === void 0) {
996
+ for (const listener of hostListeners) {
997
+ try {
998
+ listener(msg.event, msg.value);
999
+ } catch (err) {
1000
+ reportUncaught("Uncaught", err);
1001
+ }
1002
+ }
1003
+ return;
1004
+ }
1005
+ const call = hostCalls.get(msg.id);
1006
+ if (!call) return;
1007
+ hostCalls.delete(msg.id);
1008
+ call.done();
1009
+ if (msg.error !== void 0) call.reject(hostError(msg.error));
1010
+ else call.resolve(msg.result);
1011
+ }
1012
+ async function preload(urls, base) {
1013
+ for (const specifier of Array.isArray(urls) ? urls : []) {
1014
+ try {
1015
+ const mod = await import(new URL(specifier, base).href);
1016
+ if (typeof mod.default === "function") await mod.default({ host, jupyter: jupyter.namespace });
1017
+ } catch (err) {
1018
+ reportUncaught(`Preloading ${specifier} failed`, err);
1019
+ }
1020
+ }
1021
+ }
955
1022
  var handle = null;
956
1023
  function install({ base, cdn: cdnBase }) {
957
1024
  if (typeof cdnBase === "string") cdn = cdnBase.endsWith("/") ? cdnBase : cdnBase + "/";
@@ -965,6 +1032,8 @@ function install({ base, cdn: cdnBase }) {
965
1032
  resolvePath,
966
1033
  barrier: () => 0,
967
1034
  broadcastResult: jupyter.broadcastResult,
1035
+ // execute_request's user_expressions (#340).
1036
+ formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
968
1037
  async commDispatch(msgType, content, metadata, buffers64) {
969
1038
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
970
1039
  return await jupyter.commDispatch(msgType, content, metadata, buffers);
@@ -985,6 +1054,7 @@ function install({ base, cdn: cdnBase }) {
985
1054
  async function answer(req) {
986
1055
  if (req.op === "info") {
987
1056
  install(req);
1057
+ await preload(req.preload, req.base);
988
1058
  return { userAgent: globalThis.navigator?.userAgent ?? "" };
989
1059
  }
990
1060
  if (handle === null) throw new Error("the driver wasn't sent info first");
@@ -992,6 +1062,7 @@ async function answer(req) {
992
1062
  }
993
1063
  globalThis.addEventListener("message", (event) => {
994
1064
  const req = event.data;
1065
+ if (req?.op === "host") return receiveHost(req);
995
1066
  answer(req).then(
996
1067
  (reply) => post({ kind: "reply", id: req.id, ...reply }),
997
1068
  (err) => post({ kind: "reply", id: req.id, failure: String(err?.stack ?? err) })