@tlockney/cdp-kernel 0.5.0 → 0.6.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 (44) hide show
  1. package/README.md +27 -6
  2. package/dist/cdp.d.ts +2 -0
  3. package/dist/cdp.js +6 -1
  4. package/dist/celld-target.js +9 -12
  5. package/dist/children.d.ts +25 -3
  6. package/dist/children.js +84 -2
  7. package/dist/deno-types.d.ts +30 -0
  8. package/dist/deno-types.js +93 -0
  9. package/dist/kernel.d.ts +7 -1
  10. package/dist/kernel.js +187 -29
  11. package/dist/language-service-worker.d.ts +1 -0
  12. package/dist/language-service-worker.js +88 -0
  13. package/dist/language-service.d.ts +64 -0
  14. package/dist/language-service.js +128 -0
  15. package/dist/main.js +102 -8
  16. package/dist/mod.d.ts +1 -0
  17. package/dist/mod.js +1 -0
  18. package/dist/notebook-types.d.ts +62 -0
  19. package/dist/notebook-types.js +310 -0
  20. package/dist/package.d.ts +2 -2
  21. package/dist/package.js +2 -2
  22. package/dist/parent.d.ts +12 -0
  23. package/dist/parent.js +39 -0
  24. package/dist/port-holder.d.ts +13 -0
  25. package/dist/port-holder.js +105 -0
  26. package/dist/sourcemap.d.ts +7 -2
  27. package/dist/sourcemap.js +9 -3
  28. package/dist/strip-esbuild.d.ts +2 -0
  29. package/dist/strip-esbuild.js +44 -0
  30. package/dist/strip-sucrase.d.ts +2 -0
  31. package/dist/strip-sucrase.js +78 -0
  32. package/dist/target.d.ts +27 -3
  33. package/dist/target.js +193 -36
  34. package/dist/transform.d.ts +21 -1
  35. package/dist/transform.js +38 -42
  36. package/dist/workerd-target.js +1 -1
  37. package/dist/workers-project.js +13 -2
  38. package/dist/zmtp.d.ts +2 -0
  39. package/dist/zmtp.js +12 -0
  40. package/package.json +9 -4
  41. package/runtime/bootstrap.mjs +113 -33
  42. package/runtime/introspect.mjs +1 -1
  43. package/runtime/node-hooks.d.mts +6 -0
  44. package/runtime/node-hooks.mjs +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "type": "module",
22
22
  "engines": {
23
- "node": ">=24"
23
+ "node": ">=22.18"
24
24
  },
25
25
  "bin": {
26
26
  "cdp-kernel": "dist/main.js"
@@ -64,18 +64,23 @@
64
64
  "prepack": "npm run build",
65
65
  "test": "node --test --test-timeout=300000 test/*.test.ts",
66
66
  "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
67
+ "bench": "uv run --with jupyter_client python test/bench.py",
68
+ "soak": "uv run --with jupyter_client python test/soak.py",
67
69
  "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
68
70
  },
69
71
  "dependencies": {
70
72
  "acorn": "^8.15.0",
71
73
  "acorn-walk": "^8.3.4",
72
- "esbuild": "^0.25.10"
74
+ "esbuild": "^0.25.10",
75
+ "typescript": "^6.0.3"
73
76
  },
74
77
  "devDependencies": {
75
78
  "@types/node": "^24.19.2",
76
- "typescript": "^5.9.3",
77
79
  "vitepress": "^1.6.4",
78
80
  "workerd": "1.20261015.1",
79
81
  "wrangler": "4.151.0"
82
+ },
83
+ "optionalDependencies": {
84
+ "sucrase": "^3.35.1"
80
85
  }
81
86
  }
@@ -2,8 +2,8 @@
2
2
  // node --inspect=127.0.0.1:0 runtime/bootstrap.mjs
3
3
  // deno run -A --inspect=127.0.0.1:0 runtime/bootstrap.mjs
4
4
  // then drives it over CDP. Everything the host needs from inside the
5
- // runtime lives on globalThis.__cdpk; output to the host goes through the
6
- // `__cdpk_send` CDP binding (Runtime.addBinding) plus plain stdout/stderr.
5
+ // runtime lives on globalThis.__cdpk; output to the host goes over plain
6
+ // stdout/stderr, with messages as records on stdout (see send below).
7
7
  // Bun is launched as `bun runtime/bootstrap.mjs` and driven over a pipe
8
8
  // instead; see bun-channel.mjs.
9
9
  //
@@ -27,7 +27,7 @@ const cwdUrl = pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/");
27
27
  // Stand-in referrer for cell code, like Deno's ./$deno$jupyter_repl.mts.
28
28
  const replUrl = new URL("$cdpk$repl.mjs", cwdUrl);
29
29
 
30
- const { inspect, inspectPlain, readFile, writeOut, writeErr, readStdinLine } = await platform();
30
+ const { inspect, inspectPlain, readFile, writeOut, writeErr, readStdin } = await platform();
31
31
 
32
32
  function base64(bytes) {
33
33
  let binary = "";
@@ -38,11 +38,12 @@ function base64(bytes) {
38
38
  }
39
39
 
40
40
  // --- Output ordering ----------------------------------------------------
41
- // stdout/stderr reach the host over pipes; display messages over the
42
- // WebSocket. To keep them in order, every binding message is preceded by a
43
- // numbered marker written to both pipes. The host holds the message until
44
- // both pipes have delivered that marker, and a gated marker ("m") holds
45
- // later pipe output until the message has gone out.
41
+ // stdout/stderr reach the host over pipes, and so do messages: as records
42
+ // on stdout on Node and Deno, over Bun's channel on Bun. Every message has
43
+ // a number; stderr gets a gated marker ("m") with it, and stdout the record
44
+ // (Bun: a gated marker on both pipes). The host holds the message until
45
+ // both pipes have reached that number, and holds later pipe output until
46
+ // the message has gone out.
46
47
  let barrierSeq = 0;
47
48
  function barrier(gated = false) {
48
49
  const n = ++barrierSeq;
@@ -52,15 +53,32 @@ function barrier(gated = false) {
52
53
  return n;
53
54
  }
54
55
 
56
+ // Node and Deno: messages were a CDP binding's events, but an event just
57
+ // before an evaluate's reply delays the reply ~40 ms (#271). A record is
58
+ // the message's JSON, which holds no raw ESC, between "\x1b[cdpk:<" and
59
+ // "\x1b]". Cells can call this too; the host drops what isn't a message
60
+ // (#122).
61
+ let attached = false;
62
+ function sendRecord(text) {
63
+ text = String(text);
64
+ if (attached && !text.includes("\x1b")) writeOut(`\x1b[cdpk:<${text}\x1b]`);
65
+ }
66
+ if (!isBun) globalThis.__cdpk_send = sendRecord;
67
+
55
68
  function send(payload) {
56
- const binding = globalThis.__cdpk_send;
57
- if (typeof binding !== "function") return; // host not attached yet
69
+ if (isBun ? typeof globalThis.__cdpk_send !== "function" : !attached) return; // host not attached yet
58
70
  // Serialize before writing the marker: if the payload can't be
59
71
  // serialized (a BigInt, a cycle), the error reaches the cell without a
60
72
  // gated marker the host would wait on forever (#129). `payload` always
61
73
  // has a `kind`, so `body` is never "{}".
62
74
  const body = JSON.stringify(payload);
63
- binding(`{"barrier":${barrier(true)},${body.slice(1)}`);
75
+ if (isBun) {
76
+ globalThis.__cdpk_send(`{"barrier":${barrier(true)},${body.slice(1)}`);
77
+ return;
78
+ }
79
+ const n = ++barrierSeq;
80
+ writeErr(`\x1b[cdpk:${n}m]\x1b`);
81
+ sendRecord(`{"barrier":${n},${body.slice(1)}`);
64
82
  }
65
83
 
66
84
  const jupyter = createJupyter({
@@ -74,7 +92,7 @@ const jupyter = createJupyter({
74
92
  // stdin for its JSON-line reply.
75
93
  input(prompt, password) {
76
94
  send({ kind: "input_request", prompt, password });
77
- const line = readStdinLine();
95
+ const line = readInputReply();
78
96
  if (line === null) return null;
79
97
  const reply = JSON.parse(line);
80
98
  if (reply.interrupted === true) {
@@ -157,13 +175,23 @@ function hold(value) {
157
175
  return promise;
158
176
  }
159
177
 
178
+ // Yields one macrotask. Code the inspector dispatches calls this, and Node
179
+ // and Deno dispatch a message that arrives while JavaScript runs from a V8
180
+ // interrupt, which can land inside their timer code. A setTimeout there can
181
+ // create a timer list while the timer heap is mid-update, and the heap then
182
+ // loses it: every later setTimeout of that duration never fires (#237).
183
+ // setImmediate only appends to a linked list.
184
+ const yieldTask = () => new Promise((resolve) => setImmediate(resolve));
185
+
160
186
  // Adds the cause to an error from a Deno member the --deno-compat shim
161
187
  // lacks (#172); the identity everywhere else.
162
188
  let explainError = (text) => text;
163
189
 
164
190
  globalThis.__cdpk = {
191
+ // The host calls this once it reads message records (Node and Deno).
192
+ attach: () => void (attached = true),
165
193
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
166
- tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
194
+ tick: yieldTask,
167
195
  // Node: the resolve hook re-parents these imports. Deno would resolve
168
196
  // relative paths against this file, so denoImport does it here.
169
197
  import: (specifier, options) =>
@@ -196,10 +224,19 @@ globalThis.__cdpk = {
196
224
  // Frontend comm messages (#9), from the host's shell channel. Handlers
197
225
  // run after one macrotask, like cells, so interrupt can reach them.
198
226
  async commDispatch(msgType, content, metadata, buffers64) {
199
- await new Promise((resolve) => setTimeout(resolve, 0));
227
+ await yieldTask();
200
228
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
201
229
  return await jupyter.commDispatch(msgType, content, metadata, buffers);
202
230
  },
231
+ // The same, for a message the host wrote to stdin (Node and Deno). A
232
+ // value passed as a CDP call argument is compiled as source, and V8's
233
+ // compilation cache keeps that source until the heap nears its limit, so
234
+ // each message's data would stay in memory (#287). Read before the first
235
+ // await, so messages are taken in the order the host wrote them.
236
+ commDispatchFromStdin() {
237
+ const { msgType, content, metadata, buffers } = readCommRecord();
238
+ return globalThis.__cdpk.commDispatch(msgType, content, metadata, buffers);
239
+ },
203
240
  describeError(value) {
204
241
  if (value instanceof Error) {
205
242
  const message = explainError(value.message);
@@ -308,21 +345,24 @@ if (isBun) {
308
345
  });
309
346
  }
310
347
 
348
+ function writeAllSync(stream, bytes) {
349
+ while (bytes.length > 0) bytes = bytes.subarray(stream.writeSync(bytes));
350
+ }
351
+
311
352
  async function platform() {
312
353
  if (isDeno) {
313
- const stdinBuf = new Uint8Array(1);
314
354
  return {
315
355
  inspect: (v) => Deno.inspect(v, { colors: true, depth: 4 }),
316
356
  inspectPlain: (v) => Deno.inspect(v, { colors: false, depth: 2 }),
317
357
  readFile: (p) => Deno.readFileSync(p),
318
- writeOut: (s) => Deno.stdout.writeSync(ENC.encode(s)),
319
- writeErr: (s) => Deno.stderr.writeSync(ENC.encode(s)),
320
- readStdinLine: () => readLine(() => (Deno.stdin.readSync(stdinBuf) ? stdinBuf[0] : null)),
358
+ // writeSync can write part of the buffer; a record must go out whole.
359
+ writeOut: (s) => writeAllSync(Deno.stdout, ENC.encode(s)),
360
+ writeErr: (s) => writeAllSync(Deno.stderr, ENC.encode(s)),
361
+ readStdin: (bytes) => Deno.stdin.readSync(bytes) ?? 0,
321
362
  };
322
363
  }
323
364
  const util = await import("node:util");
324
365
  const fs = await import("node:fs");
325
- const buf = Buffer.alloc(1);
326
366
  // Node writes to a pipe without blocking and queues what doesn't fit for
327
367
  // the event loop to flush. input() blocks the main thread in readSync, so
328
368
  // queued output, and the input_request's barrier marker after it, would
@@ -335,19 +375,20 @@ async function platform() {
335
375
  inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
336
376
  inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
337
377
  readFile: (p) => fs.readFileSync(p),
338
- writeOut: (s) => process.stdout.write(s),
339
- writeErr: (s) => process.stderr.write(s),
340
- readStdinLine: () =>
341
- readLine(() => {
342
- for (;;) {
343
- try {
344
- return fs.readSync(0, buf, 0, 1, null) === 1 ? buf[0] : null;
345
- } catch (err) {
346
- if (err.code !== "EAGAIN") throw err;
347
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
348
- }
378
+ // Bound now: a cell or library that patches process.stdout.write to
379
+ // capture output must not swallow message records (#271).
380
+ writeOut: process.stdout.write.bind(process.stdout),
381
+ writeErr: process.stderr.write.bind(process.stderr),
382
+ readStdin: (bytes) => {
383
+ for (;;) {
384
+ try {
385
+ return fs.readSync(0, bytes, 0, bytes.length, null);
386
+ } catch (err) {
387
+ if (err.code !== "EAGAIN") throw err;
388
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
349
389
  }
350
- }),
390
+ }
391
+ },
351
392
  };
352
393
  }
353
394
 
@@ -376,12 +417,51 @@ async function syncBunOutput() {
376
417
  }
377
418
  }
378
419
 
379
- function readLine(nextByte) {
420
+ // The host writes two kinds of record to stdin, both read synchronously: a
421
+ // prompt's reply, as one JSON line, and a frontend comm message (#287), as
422
+ // a `comm <length>` line followed by that many bytes of JSON.
423
+ const COMM_HEADER = /^comm (\d+)$/;
424
+
425
+ /** `n` bytes from stdin, or null if it ends first. */
426
+ function readExactly(n) {
427
+ const bytes = new Uint8Array(n);
428
+ for (let at = 0; at < n;) {
429
+ const got = readStdin(bytes.subarray(at));
430
+ if (!got) return null;
431
+ at += got;
432
+ }
433
+ return bytes;
434
+ }
435
+
436
+ function readLine() {
380
437
  const bytes = [];
381
438
  for (;;) {
382
- const byte = nextByte();
439
+ const byte = readExactly(1)?.[0] ?? null;
383
440
  if (byte === null) return bytes.length ? new TextDecoder().decode(new Uint8Array(bytes)) : null;
384
441
  if (byte === 0x0a) return new TextDecoder().decode(new Uint8Array(bytes));
385
442
  bytes.push(byte);
386
443
  }
387
444
  }
445
+
446
+ /** The next prompt reply's line, past any comm record left unread. */
447
+ function readInputReply() {
448
+ for (;;) {
449
+ const line = readLine();
450
+ const comm = line === null ? null : COMM_HEADER.exec(line);
451
+ if (comm === null) return line;
452
+ readExactly(Number(comm[1]));
453
+ }
454
+ }
455
+
456
+ /** The next comm message, past any prompt reply left unread. */
457
+ function readCommRecord() {
458
+ for (;;) {
459
+ const line = readLine();
460
+ if (line === null) throw new Error("stdin closed before a comm message arrived");
461
+ const comm = COMM_HEADER.exec(line);
462
+ if (comm === null) continue;
463
+ const body = readExactly(Number(comm[1]));
464
+ if (body === null) throw new Error("stdin closed in the middle of a comm message");
465
+ return JSON.parse(new TextDecoder().decode(body));
466
+ }
467
+ }
@@ -77,7 +77,7 @@ function safeSource(fn) {
77
77
  function propertyNames(value) {
78
78
  if ((typeof value !== "object" && typeof value !== "function") || value === null) return [];
79
79
  try {
80
- // A function's own internals aren't worth listing; older V8 (Node 24)
80
+ // A function's own internals aren't worth listing; older V8 (Node 22 and 24)
81
81
  // also gives sloppy-mode functions own `arguments` and `caller`.
82
82
  const internal = ["length", "name", "prototype", "arguments", "caller"];
83
83
  return Object.getOwnPropertyNames(value).filter((n) => !(typeof value === "function" && internal.includes(n)));
@@ -47,6 +47,12 @@ export function ensureInstalledAsync(
47
47
  run?: (...args: Parameters<InstallRunner>) => ReturnType<InstallRunner> | Promise<ReturnType<InstallRunner>>;
48
48
  },
49
49
  ): Promise<{ dir: string; alias: string }>;
50
+ /** Where a package is installed in the cache, or null; never installs. */
51
+ export function installedPackageDir(
52
+ pkg: { name: string; range: string },
53
+ installer?: Installer,
54
+ options?: { lockFile?: string | null },
55
+ ): { dir: string; alias: string } | null;
50
56
 
51
57
  export const LOCK_FILE: string;
52
58
  export interface Lock {
@@ -320,6 +320,17 @@ export function ensureInstalledAsync({ name, range }, installer = "npm", { lockF
320
320
  });
321
321
  }
322
322
 
323
+ /**
324
+ * Where `name@range` is installed in the installer's cache, or null if it
325
+ * isn't: nothing is installed. For type information (#273), which must not
326
+ * start downloads.
327
+ */
328
+ export function installedPackageDir({ name, range }, installer = "npm", { lockFile = null } = {}) {
329
+ const dir = cacheDirPath(installer);
330
+ const { alias } = lockedRequest(name, range, lockFile);
331
+ return installedVersion(dir, alias) === null ? null : { dir, alias };
332
+ }
333
+
323
334
  // --- Imported TypeScript (#73) --------------------------------------------------
324
335
  // Node strips types in place, so line and column numbers stay those of the
325
336
  // file, but it rejects TypeScript that needs code generation. Such a file