@tlockney/cdp-kernel 0.7.0 → 0.7.2

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.
@@ -18,17 +18,18 @@ import { Buffer } from "node:buffer";
18
18
  import process from "node:process";
19
19
  import { execFileSync, spawn } from "node:child_process";
20
20
  import { randomBytes } from "node:crypto";
21
- import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
22
- import { builtinModules } from "node:module";
21
+ import { copyFileSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
22
+ import { builtinModules, createRequire } from "node:module";
23
23
  import { tmpdir } from "node:os";
24
- import { dirname, join } from "node:path";
24
+ import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
  import { build } from "esbuild";
27
27
  import { asEvaluateResult } from "./cdp.js";
28
28
  import { transformCellForEval } from "./transform.js";
29
29
  import { runtimeDir } from "./package.js";
30
30
  import { killOnExit } from "./children.js";
31
- import { Interrupted, isTargetMessage } from "./target.js";
31
+ import { Interrupted, isTargetMessage, parseBundle, parseJson } from "./target.js";
32
+ import { withStartTimeout } from "./target-api.js";
32
33
  import { connectUnixWebSocket } from "./ws-client.js";
33
34
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
34
35
  import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
@@ -87,6 +88,50 @@ export function findWorkerd(cwd) {
87
88
  return "workerd";
88
89
  }
89
90
  }
91
+ /**
92
+ * The native workerd binary behind `executable`. npm's workerd package
93
+ * installs bin/workerd as a Node script that runs the platform package's
94
+ * binary with execFileSync, and only its postinstall swaps the binary in.
95
+ * When install scripts don't run (the npm that comes with Node 27 blocks
96
+ * those not in allowScripts), killing that script leaves the binary running
97
+ * with the driver socket open, so it's never what the target spawns (#397).
98
+ * Anything that isn't the workerd package's script is returned as given.
99
+ */
100
+ export function nativeWorkerd(executable) {
101
+ const candidates = [];
102
+ let path = executable;
103
+ if (!isAbsolute(path) && !path.includes("/") && !path.includes("\\")) {
104
+ const dir = (process.env.PATH ?? "").split(delimiter).find((d) => d && existsSync(join(d, path)));
105
+ if (dir === undefined)
106
+ return executable;
107
+ path = join(dir, path);
108
+ }
109
+ // node_modules/.bin/workerd, a link or (on Windows) a .cmd shim.
110
+ if (basename(dirname(path)) === ".bin")
111
+ candidates.push(join(dirname(dirname(path)), "workerd"));
112
+ try {
113
+ // A link to <package>/bin/workerd, as a global install makes.
114
+ candidates.push(dirname(dirname(realpathSync(path))));
115
+ }
116
+ catch {
117
+ return executable;
118
+ }
119
+ for (const pkg of candidates) {
120
+ try {
121
+ const { name } = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8"));
122
+ if (name !== "workerd")
123
+ continue;
124
+ // The package's main export is the binary's path.
125
+ const binary = createRequire(join(pkg, "package.json"))(join(pkg, "lib", "main.js")).default;
126
+ if (typeof binary === "string" && existsSync(binary))
127
+ return binary;
128
+ }
129
+ catch {
130
+ // Not the npm package, or one this doesn't understand: use it as given.
131
+ }
132
+ }
133
+ return executable;
134
+ }
90
135
  /** "workerd 2026-10-15" -> "2026-10-15": the newest date it supports. */
91
136
  export function compatibilityDateOf(versionOutput) {
92
137
  const m = /\b(\d{4}-\d{2}-\d{2})\b/.exec(versionOutput);
@@ -188,8 +233,30 @@ function isWorkerdIn(pid, dir) {
188
233
  try {
189
234
  if (process.platform === "linux")
190
235
  return readlinkSync(`/proc/${pid}/cwd`) === dir;
191
- const args = execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8" });
192
- return /workerd/.test(args) && args.includes("config.capnp");
236
+ // No cwd to compare elsewhere: the process must at least be ours and
237
+ // look like a workerd (#335).
238
+ const [uid, ...args] = execFileSync("ps", ["-o", "uid=,args=", "-p", String(pid)], { encoding: "utf8" }).trim().split(/\s+/);
239
+ const command = args.join(" ");
240
+ return Number(uid) === process.getuid?.() && /workerd/.test(command) && command.includes("config.capnp");
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ }
246
+ /**
247
+ * Whether `dir` is a directory mkdtemp made for this user: not a symlink,
248
+ * owned by us, and closed to everyone else. Anyone can create entries in
249
+ * tmpdir(), so nothing in a directory that fails this is trusted (#335).
250
+ * Windows has no uids; its tmpdir() is per-user.
251
+ */
252
+ function isOwnPrivateDir(dir) {
253
+ try {
254
+ const stat = lstatSync(dir);
255
+ if (!stat.isDirectory())
256
+ return false;
257
+ if (process.getuid === undefined)
258
+ return true;
259
+ return stat.uid === process.getuid() && (stat.mode & 0o077) === 0;
193
260
  }
194
261
  catch {
195
262
  return false;
@@ -210,12 +277,14 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
210
277
  }
211
278
  for (const name of names) {
212
279
  const dir = join(root, name);
280
+ if (!isOwnPrivateDir(dir))
281
+ continue;
213
282
  let owner;
214
283
  try {
215
284
  owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
216
285
  }
217
286
  catch {
218
- continue; // Another user's, from before owner files, or being set up.
287
+ continue; // From before owner files, or being set up.
219
288
  }
220
289
  if (typeof owner.host !== "number" || pidAlive(owner.host))
221
290
  continue;
@@ -227,7 +296,12 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
227
296
  // Gone already.
228
297
  }
229
298
  }
230
- rmSync(dir, { recursive: true, force: true });
299
+ try {
300
+ rmSync(dir, { recursive: true, force: true });
301
+ }
302
+ catch {
303
+ // Cleanup is best effort; it must not stop this kernel starting.
304
+ }
231
305
  }
232
306
  }
233
307
  /** The driver on a workerd of its own, behind a Unix socket. */
@@ -241,7 +315,7 @@ export class RawWorkerd {
241
315
  this.runtime = runtime;
242
316
  }
243
317
  static async start(cwd, executable) {
244
- const workerd = executable ?? findWorkerd(cwd);
318
+ const workerd = nativeWorkerd(executable ?? findWorkerd(cwd));
245
319
  const compatibilityDate = compatibilityDateOf(execFileSync(workerd, ["--version"], { encoding: "utf8" }));
246
320
  const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
247
321
  // mkdtemp makes the directory 0700: only this user reaches the socket,
@@ -295,7 +369,7 @@ export class RawWorkerd {
295
369
  }
296
370
  })();
297
371
  try {
298
- const host = new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
372
+ const host = new RawWorkerd(child, await withStartTimeout(Promise.race([ready, exited]), "workerd"), compatibilityDate);
299
373
  starting = false;
300
374
  early = "";
301
375
  return host;
@@ -360,7 +434,7 @@ export class WorkerdTarget {
360
434
  let notice;
361
435
  if (project !== null) {
362
436
  try {
363
- host = await startMiniflare(project, cwd);
437
+ host = await startMiniflare(project, cwd, options.startTimeoutMs);
364
438
  }
365
439
  catch (err) {
366
440
  // Like a broken deno.json (#13): say why in the first cell, and run
@@ -370,16 +444,18 @@ export class WorkerdTarget {
370
444
  }
371
445
  }
372
446
  host ??= await RawWorkerd.start(cwd, options.executable);
373
- return await WorkerdTarget.open(host, cwd, events, "workerd", notice);
447
+ return await WorkerdTarget.open(host, cwd, events, "workerd", { notice, startTimeoutMs: options.startTimeoutMs });
374
448
  }
375
449
  /**
376
450
  * A target over a started host's session. `notice` (a project that
377
451
  * failed to load) is replaced by the bindings when the host has a project.
378
452
  */
379
- static async open(host, cwd, events, runtime, notice) {
453
+ static async open(host, cwd, events, runtime, options = {}) {
380
454
  const target = new WorkerdTarget(host, cwd, events, runtime);
455
+ let notice = options.notice;
381
456
  try {
382
- const { v8, bindings, worker } = await target.#request("info");
457
+ // A driver that never answers is stopped (#336).
458
+ const { v8, bindings, worker } = await withStartTimeout(target.#request("info"), `the ${runtime} driver`, options.startTimeoutMs);
383
459
  if (typeof v8 === "string" && v8)
384
460
  target.versions.engine = `V8 ${v8}`;
385
461
  if (host.project !== undefined) {
@@ -396,7 +472,15 @@ export class WorkerdTarget {
396
472
  return target;
397
473
  }
398
474
  #receive(text) {
399
- const msg = JSON.parse(text);
475
+ const msg = parseJson(text);
476
+ if (msg === undefined) {
477
+ // The driver only sends JSON; a frame that isn't means the session is
478
+ // broken, which ends the target rather than the kernel (#337).
479
+ this.#stopNote = `[cdp-kernel] ${this.runtime} sent a message that isn't JSON, so it was stopped; ` +
480
+ "it restarts on the next cell, without the earlier cells' state";
481
+ this.kill();
482
+ return;
483
+ }
400
484
  if (isRecord(msg) && msg.kind === "stream") {
401
485
  this.#events.stream(msg.name === "stderr" ? "stderr" : "stdout", String(msg.text));
402
486
  }
@@ -519,6 +603,10 @@ export class WorkerdTarget {
519
603
  }
520
604
  return { handled: reply.handled === true };
521
605
  }
606
+ async format(value) {
607
+ const reply = await this.#request("format", { handle: value.objectId });
608
+ return parseBundle(reply.value);
609
+ }
522
610
  async describeError(exception) {
523
611
  if (exception.objectId === undefined)
524
612
  return { name: "Error", message: exception.description ?? "", stack: "" };
@@ -24,4 +24,4 @@ export declare function bundleProject(main: string, define?: {
24
24
  [name: string]: string;
25
25
  }): Promise<string>;
26
26
  /** Starts the driver on the project's Miniflare with its bindings. */
27
- export declare function startMiniflare(configPath: string, cwd: string): Promise<WorkerHost>;
27
+ export declare function startMiniflare(configPath: string, cwd: string, startTimeoutMs?: number): Promise<WorkerHost>;
@@ -17,6 +17,7 @@ import { dirname, join } from "node:path";
17
17
  import { build } from "esbuild";
18
18
  import { childPids, reap } from "./children.js";
19
19
  import { runtimeDir } from "./package.js";
20
+ import { withStartTimeout } from "./target-api.js";
20
21
  import { cellMeta, WORKER_MODULES } from "./workerd-target.js";
21
22
  export const WRANGLER_CONFIGS = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
22
23
  /** The nearest wrangler config at or above `cwd`, or null. */
@@ -157,7 +158,7 @@ class MiniflareHost {
157
158
  }
158
159
  }
159
160
  /** Starts the driver on the project's Miniflare with its bindings. */
160
- export async function startMiniflare(configPath, cwd) {
161
+ export async function startMiniflare(configPath, cwd, startTimeoutMs) {
161
162
  const { wrangler, miniflare, version } = loadTooling(configPath);
162
163
  const projectDir = dirname(configPath);
163
164
  const { workerOptions, main, define } = wrangler.unstable_getMiniflareWorkerOptions(configPath);
@@ -190,10 +191,10 @@ export async function startMiniflare(configPath, cwd) {
190
191
  const before = new Set(childPids(process.pid, "workerd"));
191
192
  const mf = new miniflare.Miniflare(v5 && miniflare.convertV4MiniflareOptions ? miniflare.convertV4MiniflareOptions(options) : options);
192
193
  try {
193
- await mf.ready;
194
194
  // Miniflare listens on a loopback TCP port; the token is what keeps
195
195
  // other local processes from the driver.
196
- const res = await mf.dispatchFetch("http://cdpk/", { headers: { Upgrade: "websocket", "x-cdpk-token": token } });
196
+ const connect = mf.ready.then(() => mf.dispatchFetch("http://cdpk/", { headers: { Upgrade: "websocket", "x-cdpk-token": token } }));
197
+ const res = await withStartTimeout(connect, "Miniflare", startTimeoutMs);
197
198
  if (res.status !== 101 || res.webSocket === null)
198
199
  throw new Error(`the driver answered ${res.status}: ${await res.text()}`);
199
200
  const workerd = childPids(process.pid, "workerd").filter((pid) => !before.has(pid));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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>",
@@ -59,6 +59,7 @@
59
59
  "docs:dev": "vitepress dev docs",
60
60
  "docs:build": "vitepress build docs",
61
61
  "docs:notebooks": "node docs/.vitepress/run-notebooks.ts",
62
+ "docs:debugger": "uv run --with jupyterlab --with playwright --with nbformat python test/debugger_screenshot.py",
62
63
  "docs:widgets": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/widget_screenshots.py",
63
64
  "install:node": "node src/main.ts install --runtime node",
64
65
  "install:deno": "node src/main.ts install --runtime deno",
@@ -81,6 +82,7 @@
81
82
  },
82
83
  "devDependencies": {
83
84
  "@types/node": "^24.19.2",
85
+ "medium-zoom": "^1.1.0",
84
86
  "vitepress": "^1.6.4",
85
87
  "workerd": "1.20261015.1",
86
88
  "wrangler": "4.151.0"
@@ -215,6 +215,8 @@ globalThis.__cdpk = {
215
215
  inspectPath: (path, detail) => inspectPath(path, { inspect: inspectPlain, detail }),
216
216
  resolvePath,
217
217
  broadcastResult: jupyter.broadcastResult,
218
+ // execute_request's user_expressions (#340).
219
+ formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
218
220
  // The debugger's rich variable viewer (#18), synchronously: it runs while
219
221
  // the target is paused. A value whose $display is async shows as text.
220
222
  richBundle(value) {
@@ -8,6 +8,9 @@
8
8
  //
9
9
  // A Worker has no module resolution beyond URLs, so `npm:` and `jsr:`
10
10
  // specifiers, and bare package names, load from a CDN (esm.sh by default).
11
+ //
12
+ // The page can also offer cells its own features (#384): `host` requests go
13
+ // to the page's handler, and `preload` modules run before the first cell.
11
14
 
12
15
  import { createJupyter } from "./display.mjs";
13
16
  import { createHandler } from "./evaluator.mjs";
@@ -68,6 +71,82 @@ const jupyter = createJupyter({
68
71
  let cdn = DEFAULT_CDN;
69
72
 
70
73
 
74
+ // --- The host page (#384) -----------------------------------------------------
75
+ // Requests to the page's `host` handler (src/browser/target.ts), and events
76
+ // it sends. Preload modules get this object; cells only through them.
77
+ const hostCalls = new Map();
78
+ const hostListeners = new Set();
79
+ let nextHostId = 0;
80
+
81
+ function hostError(error) {
82
+ const err = new Error(String(error?.message ?? "the host request failed"));
83
+ if (typeof error?.name === "string") err.name = error.name;
84
+ if (error?.code !== undefined) err.code = error.code;
85
+ return err;
86
+ }
87
+
88
+ const host = {
89
+ /** Resolves with the page's answer; aborting `signal` cancels the call on the page too. */
90
+ request(method, params, { signal, transfer } = {}) {
91
+ if (signal?.aborted) return Promise.reject(signal.reason);
92
+ const id = ++nextHostId;
93
+ return new Promise((resolve, reject) => {
94
+ const onAbort = () => {
95
+ hostCalls.delete(id);
96
+ post({ kind: "host", id, cancel: true });
97
+ reject(signal.reason);
98
+ };
99
+ signal?.addEventListener("abort", onAbort, { once: true });
100
+ const done = () => signal?.removeEventListener("abort", onAbort);
101
+ try {
102
+ globalThis.postMessage({ kind: "host", id, method, params }, transfer ?? []);
103
+ } catch (err) {
104
+ done(); // params structured clone can't copy
105
+ throw err;
106
+ }
107
+ hostCalls.set(id, { resolve, reject, done });
108
+ });
109
+ },
110
+ /** Calls `listener(event, value)` for each event the page sends; returns a function that stops it. */
111
+ onEvent(listener) {
112
+ hostListeners.add(listener);
113
+ return () => hostListeners.delete(listener);
114
+ },
115
+ };
116
+
117
+ function receiveHost(msg) {
118
+ if (msg.id === undefined) {
119
+ for (const listener of hostListeners) {
120
+ try {
121
+ listener(msg.event, msg.value);
122
+ } catch (err) {
123
+ reportUncaught("Uncaught", err);
124
+ }
125
+ }
126
+ return;
127
+ }
128
+ const call = hostCalls.get(msg.id);
129
+ if (!call) return; // cancelled
130
+ hostCalls.delete(msg.id);
131
+ call.done();
132
+ if (msg.error !== undefined) call.reject(hostError(msg.error));
133
+ else call.resolve(msg.result);
134
+ }
135
+
136
+ // Each module is imported in order, and a default export that is a function
137
+ // is called with the host. A module that fails is reported, and the kernel
138
+ // runs without it.
139
+ async function preload(urls, base) {
140
+ for (const specifier of Array.isArray(urls) ? urls : []) {
141
+ try {
142
+ const mod = await import(new URL(specifier, base).href);
143
+ if (typeof mod.default === "function") await mod.default({ host, jupyter: jupyter.namespace });
144
+ } catch (err) {
145
+ reportUncaught(`Preloading ${specifier} failed`, err);
146
+ }
147
+ }
148
+ }
149
+
71
150
  // --- The shim cells and the evaluator use -----------------------------------
72
151
  let handle = null;
73
152
 
@@ -83,6 +162,8 @@ function install({ base, cdn: cdnBase }) {
83
162
  resolvePath,
84
163
  barrier: () => 0,
85
164
  broadcastResult: jupyter.broadcastResult,
165
+ // execute_request's user_expressions (#340).
166
+ formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
86
167
  async commDispatch(msgType, content, metadata, buffers64) {
87
168
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
88
169
  return await jupyter.commDispatch(msgType, content, metadata, buffers);
@@ -104,6 +185,7 @@ function install({ base, cdn: cdnBase }) {
104
185
  async function answer(req) {
105
186
  if (req.op === "info") {
106
187
  install(req);
188
+ await preload(req.preload, req.base);
107
189
  return { userAgent: globalThis.navigator?.userAgent ?? "" };
108
190
  }
109
191
  if (handle === null) throw new Error("the driver wasn't sent info first");
@@ -112,6 +194,7 @@ async function answer(req) {
112
194
 
113
195
  globalThis.addEventListener("message", (event) => {
114
196
  const req = event.data;
197
+ if (req?.op === "host") return receiveHost(req);
115
198
  answer(req).then(
116
199
  (reply) => post({ kind: "reply", id: req.id, ...reply }),
117
200
  (err) => post({ kind: "reply", id: req.id, failure: String(err?.stack ?? err) }),
@@ -56,6 +56,8 @@ export function createHandler(shim, evaluate = (code) => (0, eval)(code)) {
56
56
  case "broadcast":
57
57
  await shim.broadcastResult(request.count, take(request.handle));
58
58
  return {};
59
+ case "format":
60
+ return { value: await shim.formatJson(request.handle === undefined ? undefined : take(request.handle)) };
59
61
  case "describe":
60
62
  return { value: shim.describeError(take(request.handle)) };
61
63
  case "comm":
@@ -141,6 +141,8 @@ function install(env) {
141
141
  resolvePath,
142
142
  barrier: () => 0,
143
143
  broadcastResult: jupyter.broadcastResult,
144
+ // execute_request's user_expressions (#340).
145
+ formatJson: async (value) => JSON.stringify(await jupyter.namespace.format(value)),
144
146
  async commDispatch(msgType, content, metadata, buffers64) {
145
147
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
146
148
  return await jupyter.commDispatch(msgType, content, metadata, buffers);