@tlockney/cdp-kernel 0.3.0 → 0.5.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.
Files changed (56) hide show
  1. package/README.md +60 -404
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +4 -2
  4. package/dist/bun-target.js +35 -8
  5. package/dist/cdp.d.ts +6 -1
  6. package/dist/cdp.js +25 -10
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +46 -8
  9. package/dist/children.d.ts +7 -0
  10. package/dist/children.js +32 -0
  11. package/dist/debugger.d.ts +42 -0
  12. package/dist/debugger.js +545 -0
  13. package/dist/globals.d.ts +3 -3
  14. package/dist/globals.js +3 -3
  15. package/dist/history.d.ts +18 -0
  16. package/dist/history.js +45 -0
  17. package/dist/kernel.js +211 -32
  18. package/dist/main.js +3 -1
  19. package/dist/mod.d.ts +5 -3
  20. package/dist/mod.js +5 -3
  21. package/dist/murmur2.d.ts +2 -0
  22. package/dist/murmur2.js +41 -0
  23. package/dist/package.d.ts +16 -2
  24. package/dist/package.js +26 -3
  25. package/dist/sourcemap.d.ts +22 -0
  26. package/dist/sourcemap.js +86 -0
  27. package/dist/target.d.ts +39 -3
  28. package/dist/target.js +281 -30
  29. package/dist/text.d.ts +11 -1
  30. package/dist/text.js +72 -2
  31. package/dist/transform.d.ts +19 -0
  32. package/dist/transform.js +74 -13
  33. package/dist/wire.d.ts +8 -1
  34. package/dist/wire.js +3 -1
  35. package/dist/workerd-target.d.ts +9 -1
  36. package/dist/workerd-target.js +108 -9
  37. package/dist/ws-client.d.ts +5 -1
  38. package/dist/ws-client.js +46 -13
  39. package/dist/zmtp.d.ts +7 -1
  40. package/dist/zmtp.js +30 -1
  41. package/package.json +10 -4
  42. package/runtime/bootstrap.mjs +138 -12
  43. package/runtime/bun-channel.d.mts +3 -0
  44. package/runtime/bun-channel.mjs +2 -2
  45. package/runtime/comm.mjs +11 -1
  46. package/runtime/deno-compat.d.mts +2 -0
  47. package/runtime/deno-compat.mjs +17 -2
  48. package/runtime/display.d.mts +2 -0
  49. package/runtime/display.mjs +17 -2
  50. package/runtime/evaluator.d.mts +4 -0
  51. package/runtime/evaluator.mjs +8 -0
  52. package/runtime/introspect.d.mts +13 -0
  53. package/runtime/introspect.mjs +120 -0
  54. package/runtime/node-hooks.d.mts +61 -1
  55. package/runtime/node-hooks.mjs +354 -21
  56. package/runtime/workerd-worker.mjs +82 -6
package/dist/kernel.js CHANGED
@@ -7,15 +7,17 @@
7
7
  import { Buffer } from "node:buffer";
8
8
  import process from "node:process";
9
9
  import { randomUUID } from "node:crypto";
10
- import { decodeMessage, encodeMessage, PROTOCOL_VERSION, verify } from "./wire.js";
10
+ import { decodeMessage, encodeMessage, makeHeader, PROTOCOL_VERSION, verify } from "./wire.js";
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);
@@ -26,6 +28,10 @@ export function parseConnectionInfo(json) {
26
28
  if (typeof info[port] !== "number")
27
29
  throw new Error(`connection file: missing ${port}`);
28
30
  }
31
+ for (const field of ["ip", "key"]) {
32
+ if (info[field] !== undefined && typeof info[field] !== "string")
33
+ throw new Error(`connection file: ${field} is not a string`);
34
+ }
29
35
  if (info.transport !== undefined && info.transport !== "tcp") {
30
36
  throw new Error(`unsupported transport ${info.transport}; only tcp is implemented`);
31
37
  }
@@ -44,6 +50,18 @@ export function parseConnectionInfo(json) {
44
50
  hb_port: info.hb_port,
45
51
  };
46
52
  }
53
+ /** How long interrupt_request may wait on the target before it's answered anyway (#121). */
54
+ const INTERRUPT_REPLY_WAIT_MS = 5_000;
55
+ const NO_INPUT = { value: null, interrupted: false };
56
+ /** Shell requests other than execute_request, and their reply types. */
57
+ const SHELL_REPLIES = {
58
+ kernel_info_request: "kernel_info_reply",
59
+ complete_request: "complete_reply",
60
+ is_complete_request: "is_complete_reply",
61
+ inspect_request: "inspect_reply",
62
+ history_request: "history_reply",
63
+ comm_info_request: "comm_info_reply",
64
+ };
47
65
  export class Kernel {
48
66
  #conn;
49
67
  #runtime;
@@ -52,6 +70,10 @@ export class Kernel {
52
70
  #project;
53
71
  #projectWarning = null;
54
72
  #noticeShownFor = null;
73
+ /** JupyterLab's visual debugger, for targets driven over CDP (#18). */
74
+ #debugger;
75
+ /** Cells run with store_history, for history_request (#10). */
76
+ #history = [];
55
77
  #session = randomUUID();
56
78
  #shell;
57
79
  #control;
@@ -63,6 +85,16 @@ export class Kernel {
63
85
  #parent = {};
64
86
  #allowStdin = false;
65
87
  #executing = false;
88
+ /**
89
+ * stop_on_error (#123): how many of the shell messages queued when a cell
90
+ * failed are still to come, and whether the one being handled is one.
91
+ */
92
+ #abortQueued = 0;
93
+ #aborting = false;
94
+ /** The input_request waiting for its input_reply, if any. */
95
+ #input = null;
96
+ /** An interrupt is waiting on the target. */
97
+ #interrupting = false;
66
98
  // Open comms, for comm_info_request: comm_id -> target_name (#9).
67
99
  #comms = new Map();
68
100
  #shuttingDown = false;
@@ -71,6 +103,9 @@ export class Kernel {
71
103
  this.#runtime = runtime;
72
104
  this.#denoCompat = options.denoCompat ?? false;
73
105
  this.#project = runtime === "deno" ? this.#loadProject(options.notebookDir ?? process.cwd()) : null;
106
+ this.#debugger = runtime === "node" || runtime === "deno"
107
+ ? new Debugger((event) => this.#publish("debug_event", event), () => this.#transformOptions())
108
+ : null;
74
109
  startHeartbeat(conn.hb_port, conn.ip);
75
110
  this.#shell = new RouterSocket(conn.shell_port, conn.ip);
76
111
  this.#control = new RouterSocket(conn.control_port, conn.ip);
@@ -81,8 +116,17 @@ export class Kernel {
81
116
  // Start the runtime eagerly so the first cell doesn't pay for it.
82
117
  this.#ensureTarget().catch((err) => this.#log(`target failed to start: ${err}`));
83
118
  process.on("SIGINT", () => void this.#interrupt());
119
+ // Exiting runs the exit hook that kills the runtime (src/children.ts);
120
+ // these signals' default action would skip it (#218).
121
+ for (const [signal, code] of [["SIGTERM", 143], ["SIGHUP", 129]]) {
122
+ process.on(signal, () => {
123
+ this.#shuttingDown = true;
124
+ process.exit(code);
125
+ });
126
+ }
84
127
  await Promise.all([this.#loop(this.#shell, (m, p) => this.#handleShell(m, p)),
85
- this.#loop(this.#control, (m, p) => this.#handleControl(m, p))]);
128
+ this.#loop(this.#control, (m, p) => this.#handleControl(m, p)),
129
+ this.#loop(this.#stdin, (m) => this.#handleStdin(m))]);
86
130
  }
87
131
  #log(text) {
88
132
  process.stderr.write(`[cdp-kernel] ${text}\n`);
@@ -181,7 +225,9 @@ export class Kernel {
181
225
  #ensureTarget() {
182
226
  if (this.#target)
183
227
  return Promise.resolve(this.#target);
184
- this.#targetStarting ??= this.#launchTarget().then((target) => {
228
+ this.#targetStarting ??= this.#launchTarget().then(async (target) => {
229
+ // Before any cell runs on it, so breakpoints already apply.
230
+ await this.#debugger?.attachTarget(target.debug ?? null);
185
231
  this.#target = target;
186
232
  this.#targetStarting = null;
187
233
  return target;
@@ -194,9 +240,12 @@ export class Kernel {
194
240
  #targetEvents() {
195
241
  return {
196
242
  stream: (name, text) => this.#publish("stream", { name, text }),
197
- message: (msg) => void this.#onTargetMessage(msg),
243
+ message: (msg) => void this.#onTargetMessage(msg).catch((err) => this.#log(`error handling target message: ${err}`)),
198
244
  exit: (code, note) => {
199
245
  this.#target = null;
246
+ // Its prompt, if any, died with it; a late reply is dropped.
247
+ this.#input = null;
248
+ void this.#debugger?.attachTarget(null);
200
249
  if (!this.#shuttingDown) {
201
250
  const text = note ?? `[cdp-kernel] ${this.#runtime} exited (${code}); restarting on next cell`;
202
251
  this.#publish("stream", { name: "stderr", text: `\n${text}\n` });
@@ -213,18 +262,37 @@ export class Kernel {
213
262
  });
214
263
  return;
215
264
  }
216
- let value = null;
265
+ // The answer goes to the target that asked, not one started since.
266
+ const target = this.#target;
267
+ let answer = NO_INPUT;
217
268
  try {
218
- value = await this.#requestInput(msg.prompt, msg.password);
269
+ answer = await this.#requestInput(msg.prompt, msg.password);
219
270
  }
220
271
  finally {
221
272
  // Always answer exactly once so the target's blocking read returns.
222
- this.#target?.replyInput(value);
273
+ if (target !== null && target === this.#target)
274
+ target.replyInput(answer.value, answer.interrupted);
223
275
  }
224
276
  }
225
277
  async #interrupt() {
226
- if (this.#target && this.#executing)
278
+ // A target blocked reading its prompt's answer can't run
279
+ // terminateExecution, and would never answer it. Make the prompt throw
280
+ // instead, which ends the cell (#121).
281
+ const input = this.#input;
282
+ if (input !== null) {
283
+ this.#input = null;
284
+ input.settle({ value: null, interrupted: true });
285
+ return;
286
+ }
287
+ if (!this.#target || !this.#executing)
288
+ return;
289
+ this.#interrupting = true;
290
+ try {
227
291
  await this.#target.interrupt();
292
+ }
293
+ finally {
294
+ this.#interrupting = false;
295
+ }
228
296
  }
229
297
  // --- messaging --------------------------------------------------------------
230
298
  #encode(msgType, content, parentHeader, extra = {}) {
@@ -242,6 +310,11 @@ export class Kernel {
242
310
  async #loop(socket, handle) {
243
311
  while (!this.#shuttingDown) {
244
312
  const { peer, frames } = await socket.recv();
313
+ if (socket === this.#shell) {
314
+ this.#aborting = this.#abortQueued > 0;
315
+ if (this.#aborting)
316
+ this.#abortQueued--;
317
+ }
245
318
  try {
246
319
  const msg = decodeMessage(frames);
247
320
  // Unsigned or forged messages are dropped, as in Deno.
@@ -272,22 +345,56 @@ export class Kernel {
272
345
  },
273
346
  banner: `cdp-kernel on ${this.#runtime} ${versions?.runtime ?? ""} (${versions?.engine || "?"})`,
274
347
  help_links: [],
275
- debugger: false,
348
+ debugger: this.#debugger !== null,
349
+ // What current ipykernel advertises; JupyterLab itself goes by the
350
+ // kernelspec's metadata.debugger.
351
+ supported_features: this.#debugger !== null ? ["debugger"] : [],
276
352
  };
277
353
  }
278
354
  // --- control channel ------------------------------------------------------
279
355
  async #handleControl(msg, peer) {
356
+ // Every request is wrapped in busy/idle, as on shell (#124). JupyterLab
357
+ // treats a debug request as done only once the kernel reports idle for
358
+ // it. (Jupyter Server leaves debug_request out of the kernel's status.)
359
+ this.#status("busy", msg.header);
360
+ try {
361
+ await this.#controlRequest(this.#control, msg, peer);
362
+ }
363
+ finally {
364
+ this.#status("idle", msg.header);
365
+ }
366
+ }
367
+ /** Control requests; shutdown and interrupt may also arrive on shell (#124). */
368
+ async #controlRequest(socket, msg, peer) {
280
369
  switch (msg.header.msg_type) {
281
370
  case "kernel_info_request":
282
- this.#reply(this.#control, peer, msg, "kernel_info_reply", this.#kernelInfo());
371
+ this.#reply(socket, peer, msg, "kernel_info_reply", this.#kernelInfo());
283
372
  break;
284
373
  case "interrupt_request":
285
- await this.#interrupt();
286
- this.#reply(this.#control, peer, msg, "interrupt_reply", { status: "ok" });
374
+ // Answer even if the target doesn't (#121); the interrupt goes on.
375
+ await Promise.race([
376
+ this.#interrupt().catch((err) => this.#log(`interrupt failed: ${err}`)),
377
+ new Promise((r) => setTimeout(r, INTERRUPT_REPLY_WAIT_MS)),
378
+ ]);
379
+ this.#reply(socket, peer, msg, "interrupt_reply", { status: "ok" });
287
380
  break;
381
+ case "debug_request": {
382
+ let reply;
383
+ if (this.#debugger === null) {
384
+ reply = { type: "response", request_seq: msg.content.seq, command: msg.content.command, success: false, message: `${this.#runtime} targets have no debugger`, body: {} };
385
+ }
386
+ else {
387
+ // Everything but debugInfo talks to the target, so make sure there is one.
388
+ if (msg.content.command !== "debugInfo")
389
+ await this.#ensureTarget().catch(() => null);
390
+ reply = await this.#debugger.handle(msg.content);
391
+ }
392
+ this.#reply(socket, peer, msg, "debug_reply", reply);
393
+ break;
394
+ }
288
395
  case "shutdown_request": {
289
396
  const restart = msg.content.restart === true;
290
- this.#reply(this.#control, peer, msg, "shutdown_reply", { status: "ok", restart });
397
+ this.#reply(socket, peer, msg, "shutdown_reply", { status: "ok", restart });
291
398
  this.#shuttingDown = true;
292
399
  this.#target?.kill();
293
400
  // Frontends spawn a fresh kernel even on restart; exit after the
@@ -316,10 +423,10 @@ export class Kernel {
316
423
  this.#reply(this.#shell, peer, msg, "is_complete_reply", checkIsComplete(String(msg.content.code ?? "")));
317
424
  break;
318
425
  case "inspect_request":
319
- this.#reply(this.#shell, peer, msg, "inspect_reply", { status: "ok", found: false, data: {}, metadata: {} });
426
+ this.#reply(this.#shell, peer, msg, "inspect_reply", await this.#inspect(msg));
320
427
  break;
321
428
  case "history_request":
322
- this.#reply(this.#shell, peer, msg, "history_reply", { status: "ok", history: [] });
429
+ this.#reply(this.#shell, peer, msg, "history_reply", { status: "ok", history: queryHistory(this.#history, msg.content) });
323
430
  break;
324
431
  case "comm_info_request":
325
432
  this.#reply(this.#shell, peer, msg, "comm_info_reply", { status: "ok", comms: this.#commInfo(msg.content) });
@@ -329,12 +436,44 @@ export class Kernel {
329
436
  case "comm_close":
330
437
  await this.#handleComm(msg);
331
438
  break;
439
+ case "shutdown_request":
440
+ case "interrupt_request":
441
+ // Deprecated on shell, but still allowed, and ipykernel takes them.
442
+ await this.#controlRequest(this.#shell, msg, peer);
443
+ break;
444
+ default:
445
+ this.#log(`ignoring unknown shell message ${type}`);
446
+ }
447
+ }
448
+ catch (err) {
449
+ // E.g. the runtime failed to launch. Answer anyway, so the frontend
450
+ // doesn't wait on the request forever (#125).
451
+ const replyType = SHELL_REPLIES[type];
452
+ if (replyType !== undefined) {
453
+ const name = err instanceof Error ? err.name : "Error";
454
+ const message = err instanceof Error ? err.message : String(err);
455
+ const error = { ename: name, evalue: message, traceback: [`${name}: ${message}`] };
456
+ this.#reply(this.#shell, peer, msg, replyType, { status: "error", ...error });
332
457
  }
458
+ throw err;
333
459
  }
334
460
  finally {
335
461
  this.#status("idle", msg.header);
336
462
  }
337
463
  }
464
+ /** The tooltip (Shift-Tab) for the name at the cursor (#10). */
465
+ async #inspect(msg) {
466
+ const code = String(msg.content.code ?? "");
467
+ const cursorCp = typeof msg.content.cursor_pos === "number"
468
+ ? msg.content.cursor_pos
469
+ : utf16ToCodePointIndex(code, code.length);
470
+ const path = inspectTarget(code, codePointToUtf16Index(code, cursorCp));
471
+ const detail = msg.content.detail_level === 1 ? 1 : 0;
472
+ const text = path === null ? null : await (await this.#ensureTarget()).inspect(path, detail);
473
+ return text === null
474
+ ? { status: "ok", found: false, data: {}, metadata: {} }
475
+ : { status: "ok", found: true, data: { "text/plain": text }, metadata: {} };
476
+ }
338
477
  async #complete(msg) {
339
478
  const code = String(msg.content.code ?? "");
340
479
  const cursorCp = typeof msg.content.cursor_pos === "number"
@@ -366,31 +505,53 @@ export class Kernel {
366
505
  };
367
506
  }
368
507
  async #requestInput(prompt, password) {
508
+ // The target asked while an interrupt was already waiting on it.
509
+ if (this.#interrupting)
510
+ return { value: null, interrupted: true };
369
511
  const parent = this.#parent;
370
512
  if (!this.#allowStdin || typeof parent.msg_id !== "string")
371
- return null;
513
+ return NO_INPUT;
372
514
  if (this.#stdin.peers.size === 0)
373
515
  await new Promise((r) => setTimeout(r, 100));
374
516
  if (this.#stdin.peers.size === 0)
375
- return null;
376
- this.#stdin.sendAll(this.#encode("input_request", { prompt, password }, parent));
377
- for (;;) {
378
- const { frames } = await this.#stdin.recv();
379
- const reply = decodeMessage(frames);
380
- if (!verify(this.#conn.key, reply.signedParts, reply.sig))
381
- continue;
382
- if (reply.header.msg_type === "input_reply") {
383
- return typeof reply.content.value === "string" ? reply.content.value : null;
384
- }
385
- }
517
+ return NO_INPUT;
518
+ const header = makeHeader(this.#session, "input_request");
519
+ const answer = new Promise((settle) => (this.#input = { msgId: header.msg_id, settle }));
520
+ this.#stdin.sendAll(encodeMessage(this.#session, this.#conn.key, "input_request", { prompt, password }, {
521
+ header,
522
+ parentHeader: parent,
523
+ }));
524
+ return answer;
525
+ }
526
+ /** stdin carries only input_replys. */
527
+ async #handleStdin(reply) {
528
+ if (reply.header.msg_type !== "input_reply")
529
+ return;
530
+ const input = this.#input;
531
+ // A reply with nothing waiting is late (its prompt was interrupted), and
532
+ // one naming another request is for an earlier prompt: drop both rather
533
+ // than answer the next prompt with them (#121). Some clients
534
+ // (jupyter_client's input()) send no parent header, so only a present
535
+ // one is checked.
536
+ if (input === null)
537
+ return;
538
+ const parentId = reply.parentHeader.msg_id;
539
+ if (typeof parentId === "string" && parentId !== "" && parentId !== input.msgId)
540
+ return;
541
+ this.#input = null;
542
+ input.settle({ value: typeof reply.content.value === "string" ? reply.content.value : null, interrupted: false });
386
543
  }
387
544
  async #evaluateCell(target, code) {
388
545
  // Object-literal wrapping (Deno's evaluate_line_with_object_wrapping):
389
546
  // try `({ ... })` first and fall back to the code as written.
547
+ // While debugging, cells carry the path JupyterLab gave them and keep a
548
+ // line map, so breakpoints bind and frames point at the cell (#18).
549
+ const debugging = this.#debugger?.started === true && target.debug !== undefined;
550
+ const transform = (source) => debugging && this.#debugger ? this.#debugger.cellScript(code, source) : target.transform(source, this.#transformOptions());
390
551
  const wrapped = objectLiteralCandidate(code);
391
552
  if (wrapped !== null) {
392
553
  try {
393
- const result = await target.evaluate(target.transform(wrapped, this.#transformOptions()));
554
+ const result = await target.evaluate(transform(wrapped));
394
555
  if (!result.exceptionDetails)
395
556
  return result;
396
557
  }
@@ -399,19 +560,30 @@ export class Kernel {
399
560
  throw err;
400
561
  }
401
562
  }
402
- return target.evaluate(target.transform(code, this.#transformOptions()));
563
+ return target.evaluate(transform(code));
403
564
  }
404
565
  async #execute(msg, peer) {
405
566
  const parent = msg.header;
567
+ if (this.#aborting) {
568
+ // Queued behind a failed cell: not run, not counted (#123).
569
+ this.#status("busy", parent);
570
+ this.#reply(this.#shell, peer, msg, "execute_reply", { status: "aborted" });
571
+ this.#status("idle", parent);
572
+ return;
573
+ }
406
574
  const code = String(msg.content.code ?? "");
407
575
  const silent = msg.content.silent === true;
408
- if (!silent && msg.content.store_history !== false)
576
+ if (!silent && msg.content.store_history !== false) {
409
577
  this.#executionCount++;
578
+ this.#history.push({ line: this.#executionCount, input: code });
579
+ }
410
580
  const count = this.#executionCount;
411
581
  this.#parent = parent;
412
582
  this.#allowStdin = msg.content.allow_stdin === true;
413
583
  this.#status("busy", parent);
414
- this.#publish("execute_input", { code, execution_count: count });
584
+ // Silent requests broadcast nothing on iopub but status (#124).
585
+ if (!silent)
586
+ this.#publish("execute_input", { code, execution_count: count });
415
587
  if (this.#projectWarning !== null) {
416
588
  // Shown once, in the first cell, where the user will see it.
417
589
  this.#publish("stream", { name: "stderr", text: `[cdp-kernel] ${this.#projectWarning}\n` });
@@ -461,6 +633,13 @@ export class Kernel {
461
633
  this.#executing = false;
462
634
  }
463
635
  if (error) {
636
+ if (!silent && msg.content.stop_on_error !== false) {
637
+ // Abort what was queued behind this cell (JupyterLab's Run All sends
638
+ // every cell at once), but nothing sent after the client sees the
639
+ // error. A macrotask first lets bytes already received be parsed.
640
+ await new Promise((r) => setTimeout(r, 0));
641
+ this.#abortQueued = this.#shell.incoming.size;
642
+ }
464
643
  this.#publish("error", error);
465
644
  this.#reply(this.#shell, peer, msg, "execute_reply", { status: "error", execution_count: count, ...error });
466
645
  }
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}`);
package/dist/mod.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
- * cdp-kernel: Deno's Jupyter kernel, extracted to run notebook cells in any
3
- * runtime whose inspector speaks the Chrome DevTools Protocol (Node and
4
- * Deno today).
2
+ * cdp-kernel: a Jupyter kernel for TypeScript and JavaScript, extracted from
3
+ * Deno's, that runs notebooks on several JavaScript runtimes. The kernel host
4
+ * runs on Node or Deno; cells run in a separate target runtime: Node or Deno
5
+ * over the Chrome DevTools Protocol, or Bun, workerd and celld, which
6
+ * evaluate cells in-process.
5
7
  *
6
8
  * Most people want the CLI (`cdp-kernel install`). This module exposes
7
9
  * the kernel for embedding: give it a parsed connection file and a target
package/dist/mod.js CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
- * cdp-kernel: Deno's Jupyter kernel, extracted to run notebook cells in any
3
- * runtime whose inspector speaks the Chrome DevTools Protocol (Node and
4
- * Deno today).
2
+ * cdp-kernel: a Jupyter kernel for TypeScript and JavaScript, extracted from
3
+ * Deno's, that runs notebooks on several JavaScript runtimes. The kernel host
4
+ * runs on Node or Deno; cells run in a separate target runtime: Node or Deno
5
+ * over the Chrome DevTools Protocol, or Bun, workerd and celld, which
6
+ * evaluate cells in-process.
5
7
  *
6
8
  * Most people want the CLI (`cdp-kernel install`). This module exposes
7
9
  * the kernel for embedding: give it a parsed connection file and a target
@@ -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.5.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-16";
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.5.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-16";
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,12 +75,14 @@ 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
  }
61
84
  export function cacheRoot() {
62
- const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
85
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
63
86
  return join(base, "cdp-kernel");
64
87
  }
65
88
  async function fetchText(url) {
@@ -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
+ }