@tlockney/cdp-kernel 0.3.0 → 0.4.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.
package/dist/kernel.js CHANGED
@@ -11,11 +11,13 @@ import { decodeMessage, encodeMessage, PROTOCOL_VERSION, verify } from "./wire.j
11
11
  import { PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
12
12
  import { Interrupted, Target } from "./target.js";
13
13
  import { BunTarget } from "./bun-target.js";
14
+ import { Debugger } from "./debugger.js";
14
15
  import { WorkerdTarget } from "./workerd-target.js";
15
16
  import { launchCelld } from "./celld-target.js";
16
17
  import { CellSyntaxError, objectLiteralCandidate } from "./transform.js";
17
18
  import { loadDenoProject } from "./deno-config.js";
18
- import { checkIsComplete, codePointToUtf16Index, exprAtCursor, utf16ToCodePointIndex } from "./text.js";
19
+ import { checkIsComplete, codePointToUtf16Index, exprAtCursor, inspectTarget, utf16ToCodePointIndex } from "./text.js";
20
+ import { queryHistory } from "./history.js";
19
21
  import { VERSION } from "./package.js";
20
22
  export function parseConnectionInfo(json) {
21
23
  const raw = JSON.parse(json);
@@ -52,6 +54,10 @@ export class Kernel {
52
54
  #project;
53
55
  #projectWarning = null;
54
56
  #noticeShownFor = null;
57
+ /** JupyterLab's visual debugger, for targets driven over CDP (#18). */
58
+ #debugger;
59
+ /** Cells run with store_history, for history_request (#10). */
60
+ #history = [];
55
61
  #session = randomUUID();
56
62
  #shell;
57
63
  #control;
@@ -71,6 +77,9 @@ export class Kernel {
71
77
  this.#runtime = runtime;
72
78
  this.#denoCompat = options.denoCompat ?? false;
73
79
  this.#project = runtime === "deno" ? this.#loadProject(options.notebookDir ?? process.cwd()) : null;
80
+ this.#debugger = runtime === "node" || runtime === "deno"
81
+ ? new Debugger((event) => this.#publish("debug_event", event), () => this.#transformOptions())
82
+ : null;
74
83
  startHeartbeat(conn.hb_port, conn.ip);
75
84
  this.#shell = new RouterSocket(conn.shell_port, conn.ip);
76
85
  this.#control = new RouterSocket(conn.control_port, conn.ip);
@@ -181,7 +190,9 @@ export class Kernel {
181
190
  #ensureTarget() {
182
191
  if (this.#target)
183
192
  return Promise.resolve(this.#target);
184
- this.#targetStarting ??= this.#launchTarget().then((target) => {
193
+ this.#targetStarting ??= this.#launchTarget().then(async (target) => {
194
+ // Before any cell runs on it, so breakpoints already apply.
195
+ await this.#debugger?.attachTarget(target.debug ?? null);
185
196
  this.#target = target;
186
197
  this.#targetStarting = null;
187
198
  return target;
@@ -197,6 +208,7 @@ export class Kernel {
197
208
  message: (msg) => void this.#onTargetMessage(msg),
198
209
  exit: (code, note) => {
199
210
  this.#target = null;
211
+ void this.#debugger?.attachTarget(null);
200
212
  if (!this.#shuttingDown) {
201
213
  const text = note ?? `[cdp-kernel] ${this.#runtime} exited (${code}); restarting on next cell`;
202
214
  this.#publish("stream", { name: "stderr", text: `\n${text}\n` });
@@ -272,7 +284,10 @@ export class Kernel {
272
284
  },
273
285
  banner: `cdp-kernel on ${this.#runtime} ${versions?.runtime ?? ""} (${versions?.engine || "?"})`,
274
286
  help_links: [],
275
- debugger: false,
287
+ debugger: this.#debugger !== null,
288
+ // What current ipykernel advertises; JupyterLab itself goes by the
289
+ // kernelspec's metadata.debugger.
290
+ supported_features: this.#debugger !== null ? ["debugger"] : [],
276
291
  };
277
292
  }
278
293
  // --- control channel ------------------------------------------------------
@@ -285,6 +300,29 @@ export class Kernel {
285
300
  await this.#interrupt();
286
301
  this.#reply(this.#control, peer, msg, "interrupt_reply", { status: "ok" });
287
302
  break;
303
+ case "debug_request": {
304
+ // JupyterLab treats a debug request as done only once the kernel
305
+ // reports idle for it, as ipykernel does for control messages.
306
+ // (Jupyter Server leaves debug_request out of the kernel's status.)
307
+ this.#status("busy", msg.header);
308
+ let reply;
309
+ try {
310
+ if (this.#debugger === null) {
311
+ reply = { type: "response", request_seq: msg.content.seq, command: msg.content.command, success: false, message: `${this.#runtime} targets have no debugger`, body: {} };
312
+ }
313
+ else {
314
+ // Everything but debugInfo talks to the target, so make sure there is one.
315
+ if (msg.content.command !== "debugInfo")
316
+ await this.#ensureTarget().catch(() => null);
317
+ reply = await this.#debugger.handle(msg.content);
318
+ }
319
+ this.#reply(this.#control, peer, msg, "debug_reply", reply);
320
+ }
321
+ finally {
322
+ this.#status("idle", msg.header);
323
+ }
324
+ break;
325
+ }
288
326
  case "shutdown_request": {
289
327
  const restart = msg.content.restart === true;
290
328
  this.#reply(this.#control, peer, msg, "shutdown_reply", { status: "ok", restart });
@@ -316,10 +354,10 @@ export class Kernel {
316
354
  this.#reply(this.#shell, peer, msg, "is_complete_reply", checkIsComplete(String(msg.content.code ?? "")));
317
355
  break;
318
356
  case "inspect_request":
319
- this.#reply(this.#shell, peer, msg, "inspect_reply", { status: "ok", found: false, data: {}, metadata: {} });
357
+ this.#reply(this.#shell, peer, msg, "inspect_reply", await this.#inspect(msg));
320
358
  break;
321
359
  case "history_request":
322
- this.#reply(this.#shell, peer, msg, "history_reply", { status: "ok", history: [] });
360
+ this.#reply(this.#shell, peer, msg, "history_reply", { status: "ok", history: queryHistory(this.#history, msg.content) });
323
361
  break;
324
362
  case "comm_info_request":
325
363
  this.#reply(this.#shell, peer, msg, "comm_info_reply", { status: "ok", comms: this.#commInfo(msg.content) });
@@ -335,6 +373,19 @@ export class Kernel {
335
373
  this.#status("idle", msg.header);
336
374
  }
337
375
  }
376
+ /** The tooltip (Shift-Tab) for the name at the cursor (#10). */
377
+ async #inspect(msg) {
378
+ const code = String(msg.content.code ?? "");
379
+ const cursorCp = typeof msg.content.cursor_pos === "number"
380
+ ? msg.content.cursor_pos
381
+ : utf16ToCodePointIndex(code, code.length);
382
+ const path = inspectTarget(code, codePointToUtf16Index(code, cursorCp));
383
+ const detail = msg.content.detail_level === 1 ? 1 : 0;
384
+ const text = path === null ? null : await (await this.#ensureTarget()).inspect(path, detail);
385
+ return text === null
386
+ ? { status: "ok", found: false, data: {}, metadata: {} }
387
+ : { status: "ok", found: true, data: { "text/plain": text }, metadata: {} };
388
+ }
338
389
  async #complete(msg) {
339
390
  const code = String(msg.content.code ?? "");
340
391
  const cursorCp = typeof msg.content.cursor_pos === "number"
@@ -387,10 +438,14 @@ export class Kernel {
387
438
  async #evaluateCell(target, code) {
388
439
  // Object-literal wrapping (Deno's evaluate_line_with_object_wrapping):
389
440
  // try `({ ... })` first and fall back to the code as written.
441
+ // While debugging, cells carry the path JupyterLab gave them and keep a
442
+ // line map, so breakpoints bind and frames point at the cell (#18).
443
+ const debugging = this.#debugger?.started === true && target.debug !== undefined;
444
+ const transform = (source) => debugging && this.#debugger ? this.#debugger.cellScript(code, source) : target.transform(source, this.#transformOptions());
390
445
  const wrapped = objectLiteralCandidate(code);
391
446
  if (wrapped !== null) {
392
447
  try {
393
- const result = await target.evaluate(target.transform(wrapped, this.#transformOptions()));
448
+ const result = await target.evaluate(transform(wrapped));
394
449
  if (!result.exceptionDetails)
395
450
  return result;
396
451
  }
@@ -399,14 +454,16 @@ export class Kernel {
399
454
  throw err;
400
455
  }
401
456
  }
402
- return target.evaluate(target.transform(code, this.#transformOptions()));
457
+ return target.evaluate(transform(code));
403
458
  }
404
459
  async #execute(msg, peer) {
405
460
  const parent = msg.header;
406
461
  const code = String(msg.content.code ?? "");
407
462
  const silent = msg.content.silent === true;
408
- if (!silent && msg.content.store_history !== false)
463
+ if (!silent && msg.content.store_history !== false) {
409
464
  this.#executionCount++;
465
+ this.#history.push({ line: this.#executionCount, input: code });
466
+ }
410
467
  const count = this.#executionCount;
411
468
  this.#parent = parent;
412
469
  this.#allowStdin = msg.content.allow_stdin === true;
package/dist/main.js CHANGED
@@ -42,7 +42,9 @@ function install(runtime, denoCompat, name, display) {
42
42
  // Interrupts arrive as interrupt_request on the control channel rather
43
43
  // than as SIGINT to this process.
44
44
  interrupt_mode: "message",
45
- metadata: { runtime, denoCompat, version: VERSION },
45
+ // JupyterLab enables its visual debugger from metadata.debugger (#18);
46
+ // only the targets driven over CDP have one.
47
+ metadata: { runtime, denoCompat, version: VERSION, debugger: runtime === "node" || runtime === "deno" },
46
48
  };
47
49
  writeFileSync(join(dir, "kernel.json"), JSON.stringify(spec, null, 2) + "\n");
48
50
  console.log(`installed kernelspec ${name} -> ${dir}`);
@@ -0,0 +1,2 @@
1
+ /** The hash of a string's UTF-8 bytes. */
2
+ export declare function murmur2(str: string, seed: number): number;
@@ -0,0 +1,41 @@
1
+ // MurmurHash2 (32-bit), as the Jupyter debugger uses it to name a cell's
2
+ // source (#18). JupyterLab computes a cell's path itself, as tmpFilePrefix +
3
+ // murmur2(code, hashSeed) + tmpFileSuffix, and the kernel must name the
4
+ // cell's script the same way, so this follows JupyterLab's
5
+ // packages/debugger/src/hash.ts (Modified BSD License, Copyright (c) Jupyter
6
+ // Development Team), itself adapted from Gary Court's murmurhash2_gc.js
7
+ // (MIT License, Copyright (c) 2011 Gary Court).
8
+ const M = 0x5bd1e995;
9
+ const ENCODER = new TextEncoder();
10
+ /** 32-bit multiply by M, without leaving the safe-integer range. */
11
+ function mul(x) {
12
+ return (x & 0xffff) * M + ((((x >>> 16) * M) & 0xffff) << 16);
13
+ }
14
+ /** The hash of a string's UTF-8 bytes. */
15
+ export function murmur2(str, seed) {
16
+ const data = ENCODER.encode(str);
17
+ let len = data.length;
18
+ let h = seed ^ len;
19
+ let i = 0;
20
+ while (len >= 4) {
21
+ let k = data[i] | (data[i + 1] << 8) | (data[i + 2] << 16) | (data[i + 3] << 24);
22
+ k = mul(k);
23
+ k ^= k >>> 24;
24
+ k = mul(k);
25
+ h = mul(h) ^ k;
26
+ len -= 4;
27
+ i += 4;
28
+ }
29
+ if (len === 3)
30
+ h ^= data[i + 2] << 16;
31
+ if (len >= 2)
32
+ h ^= data[i + 1] << 8;
33
+ if (len >= 1) {
34
+ h ^= data[i];
35
+ h = mul(h);
36
+ }
37
+ h ^= h >>> 13;
38
+ h = mul(h);
39
+ h ^= h >>> 15;
40
+ return h >>> 0;
41
+ }
package/dist/package.d.ts CHANGED
@@ -1,6 +1,20 @@
1
1
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
2
- export declare const VERSION = "0.3.0";
3
- export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
2
+ export declare const VERSION = "0.4.0";
3
+ /**
4
+ * The UTC day this version was released, set with VERSION. The publish
5
+ * workflow refuses a tag unless it's today or yesterday, so it can't go
6
+ * stale: see dependencyCutoff.
7
+ */
8
+ export declare const RELEASE_DATE = "2026-10-15";
9
+ /**
10
+ * Deno blocks packages less than 24 hours old (minimum dependency age), so
11
+ * a kernelspec relaunching a release from JSR would fail on release day. It
12
+ * relaunches with this cutoff instead (#12): anything published up to two
13
+ * days after the release is allowed (the release itself included, however
14
+ * late in the day its tag was pushed), and nothing newer ever is.
15
+ */
16
+ export declare function dependencyCutoff(releaseDate?: string): string;
17
+ export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
4
18
  export type HostRuntime = "node" | "deno";
5
19
  export declare function hostRuntime(): HostRuntime;
6
20
  /** Where the CLI module was loaded from. */
package/dist/package.js CHANGED
@@ -14,11 +14,32 @@ import { join, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
17
- export const VERSION = "0.3.0";
17
+ export const VERSION = "0.4.0";
18
+ /**
19
+ * The UTC day this version was released, set with VERSION. The publish
20
+ * workflow refuses a tag unless it's today or yesterday, so it can't go
21
+ * stale: see dependencyCutoff.
22
+ */
23
+ export const RELEASE_DATE = "2026-10-15";
24
+ /**
25
+ * Deno blocks packages less than 24 hours old (minimum dependency age), so
26
+ * a kernelspec relaunching a release from JSR would fail on release day. It
27
+ * relaunches with this cutoff instead (#12): anything published up to two
28
+ * days after the release is allowed (the release itself included, however
29
+ * late in the day its tag was pushed), and nothing newer ever is.
30
+ */
31
+ export function dependencyCutoff(releaseDate = RELEASE_DATE) {
32
+ const day = new Date(`${releaseDate}T00:00:00Z`);
33
+ if (Number.isNaN(day.getTime()))
34
+ throw new Error(`not a date: ${releaseDate}`);
35
+ day.setUTCDate(day.getUTCDate() + 2);
36
+ return day.toISOString().slice(0, 10);
37
+ }
18
38
  export const RUNTIME_FILES = [
19
39
  "bootstrap.mjs",
20
40
  "bun-channel.mjs",
21
41
  "evaluator.mjs",
42
+ "introspect.mjs",
22
43
  "workerd-worker.mjs",
23
44
  "display.mjs",
24
45
  "comm.mjs",
@@ -54,7 +75,9 @@ export function kernelArgv(host, execPath, origin, args) {
54
75
  }
55
76
  // Deno: a checkout needs its deno.jsonc import map; a JSR package brings
56
77
  // its own, so skip whatever config the notebook's directory has.
57
- const flags = origin.kind === "jsr" ? ["--no-config", "--no-lock"] : ["--no-lock"];
78
+ const flags = origin.kind === "jsr"
79
+ ? ["--no-config", "--no-lock", `--min-dep-age=${dependencyCutoff()}`]
80
+ : ["--no-lock"];
58
81
  const entry = origin.kind === "jsr" ? origin.specifier : origin.path;
59
82
  return [execPath, "run", "--allow-all", ...flags, entry, ...args];
60
83
  }
@@ -0,0 +1,22 @@
1
+ /** One segment's VLQ fields: generated column, source, line, column, name. */
2
+ export declare function decodeVlq(segment: string): number[];
3
+ /**
4
+ * For each generated line (0-based), the first original line (0-based) a
5
+ * segment on it maps to, or -1. Source-map fields are relative to the
6
+ * previous segment's; only the original line is tracked.
7
+ */
8
+ export declare function generatedToOriginalLines(mappings: string): number[];
9
+ /** Both directions between the lines of a cell and of its script (0-based). */
10
+ export declare class LineMap {
11
+ #private;
12
+ /** `offset`: lines the script adds before esbuild's output (the prelude). */
13
+ constructor(mappings: string, offset?: number);
14
+ /** The cell line a script line came from: its own mapping, or the nearest one above. */
15
+ toOriginal(generated: number): number;
16
+ /**
17
+ * The script line for a cell line, or for the next cell line that has
18
+ * code (a breakpoint on a blank or comment line moves down, as debuggers
19
+ * do), or null past the end.
20
+ */
21
+ toGenerated(original: number): number | null;
22
+ }
@@ -0,0 +1,86 @@
1
+ // Line mapping between a cell as written and the script that runs, for the
2
+ // debugger (#18): a breakpoint set on a cell's line must land on the line
3
+ // esbuild's output put it on, and a paused frame must report the line the
4
+ // user wrote. Only lines: Jupyter's breakpoints are per line.
5
+ const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
6
+ const DIGIT = new Map([...BASE64].map((c, i) => [c, i]));
7
+ /** One segment's VLQ fields: generated column, source, line, column, name. */
8
+ export function decodeVlq(segment) {
9
+ const values = [];
10
+ let value = 0;
11
+ let shift = 0;
12
+ for (const char of segment) {
13
+ const digit = DIGIT.get(char);
14
+ if (digit === undefined)
15
+ throw new Error(`bad base64 VLQ digit ${JSON.stringify(char)}`);
16
+ value += (digit & 31) << shift;
17
+ if (digit & 32) {
18
+ shift += 5;
19
+ continue;
20
+ }
21
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1);
22
+ value = 0;
23
+ shift = 0;
24
+ }
25
+ return values;
26
+ }
27
+ /**
28
+ * For each generated line (0-based), the first original line (0-based) a
29
+ * segment on it maps to, or -1. Source-map fields are relative to the
30
+ * previous segment's; only the original line is tracked.
31
+ */
32
+ export function generatedToOriginalLines(mappings) {
33
+ const lines = [];
34
+ let original = 0;
35
+ for (const line of mappings.split(";")) {
36
+ let first = -1;
37
+ for (const segment of line.split(",")) {
38
+ if (!segment)
39
+ continue;
40
+ const fields = decodeVlq(segment);
41
+ if (fields.length >= 4) {
42
+ original += fields[2];
43
+ if (first === -1)
44
+ first = original;
45
+ }
46
+ }
47
+ lines.push(first);
48
+ }
49
+ return lines;
50
+ }
51
+ /** Both directions between the lines of a cell and of its script (0-based). */
52
+ export class LineMap {
53
+ #toOriginal;
54
+ #toGenerated = new Map();
55
+ /** `offset`: lines the script adds before esbuild's output (the prelude). */
56
+ constructor(mappings, offset = 0) {
57
+ const fromEsbuild = generatedToOriginalLines(mappings);
58
+ this.#toOriginal = [...Array(offset).fill(-1), ...fromEsbuild];
59
+ this.#toOriginal.forEach((original, generated) => {
60
+ if (original !== -1 && !this.#toGenerated.has(original))
61
+ this.#toGenerated.set(original, generated);
62
+ });
63
+ }
64
+ /** The cell line a script line came from: its own mapping, or the nearest one above. */
65
+ toOriginal(generated) {
66
+ for (let line = Math.min(generated, this.#toOriginal.length - 1); line >= 0; line--) {
67
+ if (this.#toOriginal[line] !== -1)
68
+ return this.#toOriginal[line];
69
+ }
70
+ return 0;
71
+ }
72
+ /**
73
+ * The script line for a cell line, or for the next cell line that has
74
+ * code (a breakpoint on a blank or comment line moves down, as debuggers
75
+ * do), or null past the end.
76
+ */
77
+ toGenerated(original) {
78
+ const last = Math.max(-1, ...this.#toGenerated.keys());
79
+ for (let line = original; line <= last; line++) {
80
+ const generated = this.#toGenerated.get(line);
81
+ if (generated !== undefined)
82
+ return generated;
83
+ }
84
+ return null;
85
+ }
86
+ }
package/dist/target.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
2
  import { type CellTransformOptions, transformCell } from "./transform.ts";
3
+ import type { DebugChannel } from "./debugger.ts";
3
4
  /** Targets driven over CDP. */
4
5
  export type CdpRuntime = "node" | "deno";
5
6
  /** Bun and workerd have their own backends: src/bun-target.ts, src/workerd-target.ts. */
@@ -37,6 +38,10 @@ export interface KernelTarget {
37
38
  }>;
38
39
  propertyNames(expression: string): Promise<string[]>;
39
40
  lexicalNames(): Promise<string[]>;
41
+ /** inspect_request (#10): text describing a dotted path's value, without running anything. */
42
+ inspect(path: string, detail: number): Promise<string | null>;
43
+ /** The Debugger domain, on targets driven over CDP (#18). */
44
+ readonly debug?: DebugChannel;
40
45
  replyInput(value: string | null): void;
41
46
  kill(): void;
42
47
  }
@@ -114,6 +119,8 @@ export declare class Target implements KernelTarget {
114
119
  denoCompat?: boolean;
115
120
  denoConfig?: string;
116
121
  }): Promise<Target>;
122
+ /** The Debugger domain on this target's session, for src/debugger.ts. */
123
+ get debug(): DebugChannel;
117
124
  /** Resolves once all output and messages sent so far have been delivered. */
118
125
  flush(): Promise<void>;
119
126
  /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
@@ -149,6 +156,7 @@ export declare class Target implements KernelTarget {
149
156
  stack: string;
150
157
  }>;
151
158
  propertyNames(expression: string): Promise<string[]>;
159
+ inspect(path: string, detail: number): Promise<string | null>;
152
160
  lexicalNames(): Promise<string[]>;
153
161
  replyInput(value: string | null): void;
154
162
  kill(): void;
package/dist/target.js CHANGED
@@ -179,6 +179,8 @@ export class Target {
179
179
  #contextId;
180
180
  #messages = Promise.resolve();
181
181
  #interrupted = false;
182
+ #paused = false;
183
+ #baselineGlobals = new Set();
182
184
  versions;
183
185
  constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
184
186
  this.runtime = runtime;
@@ -271,8 +273,26 @@ export class Target {
271
273
  }
272
274
  const versions = await target.#evaluateRaw(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, engine: "V8 " + Deno.version.v8 } : { runtime: process.versions.node, engine: "V8 " + process.versions.v8 })`);
273
275
  Object.assign(target.versions, JSON.parse(String(versions.result.value)));
276
+ // The runtime's own enumerable globals, before any cell: the debugger's
277
+ // variable views show only what cells added (#18).
278
+ const globals = asEvaluateResult(await shell.send("Runtime.evaluate", { expression: "Object.keys(globalThis)", contextId, returnByValue: true }));
279
+ const names = globals.result.value;
280
+ if (Array.isArray(names))
281
+ for (const name of names)
282
+ target.#baselineGlobals.add(String(name));
283
+ shell.on("Debugger.paused", () => (target.#paused = true));
284
+ shell.on("Debugger.resumed", () => (target.#paused = false));
274
285
  return target;
275
286
  }
287
+ /** The Debugger domain on this target's session, for src/debugger.ts. */
288
+ get debug() {
289
+ return {
290
+ send: (method, params = {}) => this.#shell.send(method, params),
291
+ on: (method, listener) => this.#shell.on(method, listener),
292
+ contextId: this.#contextId,
293
+ baselineGlobals: this.#baselineGlobals,
294
+ };
295
+ }
276
296
  // Binding messages wait until both pipes have caught up to their marker,
277
297
  // and are delivered strictly in order.
278
298
  #deliver(msg, events) {
@@ -315,6 +335,9 @@ export class Target {
315
335
  */
316
336
  async interrupt() {
317
337
  this.#interrupted = true;
338
+ // A paused isolate runs nothing to terminate: let it go first (#18).
339
+ if (this.#paused)
340
+ await this.#shell.send("Debugger.resume").catch(() => { });
318
341
  await this.#control.send("Runtime.terminateExecution");
319
342
  this.#shell.abandonPending();
320
343
  }
@@ -381,8 +404,18 @@ export class Target {
381
404
  const value = res.result?.value;
382
405
  return JSON.parse(typeof value === "string" ? value : '{"name":"Error","message":"","stack":""}');
383
406
  }
407
+ /**
408
+ * Evaluates without side effects: V8 throws instead of running anything
409
+ * that would change state (a call, a getter that writes), so completing
410
+ * or inspecting `obj.` never runs user code (#10).
411
+ */
412
+ #evaluatePure(expression) {
413
+ return this.#shell
414
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, throwOnSideEffect: true })
415
+ .then(asEvaluateResult, () => null);
416
+ }
384
417
  async propertyNames(expression) {
385
- const evaluated = await this.#evaluateRaw(`(${expression})`).catch(() => null);
418
+ const evaluated = await this.#evaluatePure(`(${expression})`);
386
419
  const objectId = evaluated?.result.objectId;
387
420
  if (!objectId || evaluated?.exceptionDetails)
388
421
  return [];
@@ -390,6 +423,23 @@ export class Target {
390
423
  const list = props.result;
391
424
  return (list ?? []).map((p) => p.name);
392
425
  }
426
+ async inspect(path, detail) {
427
+ const found = await this.#evaluatePure(path);
428
+ if (!found || found.exceptionDetails)
429
+ return null;
430
+ const value = found.result;
431
+ const arg = value.objectId
432
+ ? { objectId: value.objectId }
433
+ : value.unserializableValue !== undefined
434
+ ? { unserializableValue: value.unserializableValue }
435
+ : { value: value.value };
436
+ const res = await this.callFunctionOn(`function (v, d) { return globalThis.${SHIM}.describeForInspect(v, d); }`, [
437
+ arg,
438
+ { value: detail },
439
+ ]);
440
+ const text = res.result?.value;
441
+ return typeof text === "string" ? text : null;
442
+ }
393
443
  async lexicalNames() {
394
444
  const res = await this.#shell.send("Runtime.globalLexicalScopeNames", { executionContextId: this.#contextId });
395
445
  return res.names ?? [];
package/dist/text.d.ts CHANGED
@@ -10,5 +10,11 @@ export type IsCompleteReply = {
10
10
  export declare function checkIsComplete(code: string): IsCompleteReply;
11
11
  /** The dotted expression ending at the cursor, e.g. "Math.fl". */
12
12
  export declare function exprAtCursor(code: string, cursor: number): string;
13
+ /**
14
+ * The dotted name an inspect_request means (#10): the one the cursor is in
15
+ * or just after, or, right after "name(" while typing a call, that callee.
16
+ * Null when it isn't a plain path; nothing else is evaluated.
17
+ */
18
+ export declare function inspectTarget(code: string, cursor: number): string | null;
13
19
  export declare function codePointToUtf16Index(str: string, cpOffset: number): number;
14
20
  export declare function utf16ToCodePointIndex(str: string, utf16Offset: number): number;
package/dist/text.js CHANGED
@@ -51,6 +51,23 @@ export function exprAtCursor(code, cursor) {
51
51
  const m = /[\w$.]+$/.exec(code.slice(0, cursor));
52
52
  return m ? m[0] : "";
53
53
  }
54
+ /**
55
+ * The dotted name an inspect_request means (#10): the one the cursor is in
56
+ * or just after, or, right after "name(" while typing a call, that callee.
57
+ * Null when it isn't a plain path; nothing else is evaluated.
58
+ */
59
+ export function inspectTarget(code, cursor) {
60
+ let end = cursor;
61
+ // Inside a call's parentheses with nothing typed yet: "add(|".
62
+ const before = code.slice(0, cursor);
63
+ const call = /([\w$.]+)\(\s*$/.exec(before);
64
+ if (call)
65
+ end = cursor - (before.length - before.lastIndexOf("(", cursor));
66
+ const head = /[\w$.]*$/.exec(code.slice(0, end))?.[0] ?? "";
67
+ const tail = call ? "" : (/^[\w$]*/.exec(code.slice(end))?.[0] ?? "");
68
+ const path = (head + tail).replace(/\.$/, "");
69
+ return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(path) ? path : null;
70
+ }
54
71
  // Jupyter measures cursor_pos in Unicode codepoints; JS strings index UTF-16
55
72
  // code units (denoland/deno#22771).
56
73
  export function codePointToUtf16Index(str, cpOffset) {
@@ -1,3 +1,4 @@
1
+ import { LineMap } from "./sourcemap.ts";
1
2
  export declare const SHIM = "__cdpk";
2
3
  export declare class CellSyntaxError extends Error {
3
4
  name: string;
@@ -23,6 +24,15 @@ export interface CellTransformOptions {
23
24
  /** Rewrites module-only syntax in plain JavaScript into script syntax. */
24
25
  export declare function rewriteModuleSyntax(js: string): string;
25
26
  export declare function transformCell(code: string, options?: CellTransformOptions): string;
27
+ /**
28
+ * transformCell, plus the map between the cell's lines and the script's,
29
+ * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
30
+ * script's lines are esbuild's, after the prelude's one.
31
+ */
32
+ export declare function transformCellWithLines(code: string, options?: CellTransformOptions): {
33
+ script: string;
34
+ lines: LineMap;
35
+ };
26
36
  /**
27
37
  * Like transformCell, for a target that evaluates cells itself, as global
28
38
  * code (an indirect eval), instead of through the inspector's replMode: see