@tlockney/cdp-kernel 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ 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";
21
+ import { copyFileSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { builtinModules } from "node:module";
23
23
  import { tmpdir } from "node:os";
24
24
  import { dirname, join } from "node:path";
@@ -28,7 +28,8 @@ 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";
@@ -188,8 +189,30 @@ function isWorkerdIn(pid, dir) {
188
189
  try {
189
190
  if (process.platform === "linux")
190
191
  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");
192
+ // No cwd to compare elsewhere: the process must at least be ours and
193
+ // look like a workerd (#335).
194
+ const [uid, ...args] = execFileSync("ps", ["-o", "uid=,args=", "-p", String(pid)], { encoding: "utf8" }).trim().split(/\s+/);
195
+ const command = args.join(" ");
196
+ return Number(uid) === process.getuid?.() && /workerd/.test(command) && command.includes("config.capnp");
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
202
+ /**
203
+ * Whether `dir` is a directory mkdtemp made for this user: not a symlink,
204
+ * owned by us, and closed to everyone else. Anyone can create entries in
205
+ * tmpdir(), so nothing in a directory that fails this is trusted (#335).
206
+ * Windows has no uids; its tmpdir() is per-user.
207
+ */
208
+ function isOwnPrivateDir(dir) {
209
+ try {
210
+ const stat = lstatSync(dir);
211
+ if (!stat.isDirectory())
212
+ return false;
213
+ if (process.getuid === undefined)
214
+ return true;
215
+ return stat.uid === process.getuid() && (stat.mode & 0o077) === 0;
193
216
  }
194
217
  catch {
195
218
  return false;
@@ -210,12 +233,14 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
210
233
  }
211
234
  for (const name of names) {
212
235
  const dir = join(root, name);
236
+ if (!isOwnPrivateDir(dir))
237
+ continue;
213
238
  let owner;
214
239
  try {
215
240
  owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
216
241
  }
217
242
  catch {
218
- continue; // Another user's, from before owner files, or being set up.
243
+ continue; // From before owner files, or being set up.
219
244
  }
220
245
  if (typeof owner.host !== "number" || pidAlive(owner.host))
221
246
  continue;
@@ -227,7 +252,12 @@ export function sweepStaleWorkerdDirs(root = tmpdir()) {
227
252
  // Gone already.
228
253
  }
229
254
  }
230
- rmSync(dir, { recursive: true, force: true });
255
+ try {
256
+ rmSync(dir, { recursive: true, force: true });
257
+ }
258
+ catch {
259
+ // Cleanup is best effort; it must not stop this kernel starting.
260
+ }
231
261
  }
232
262
  }
233
263
  /** The driver on a workerd of its own, behind a Unix socket. */
@@ -295,7 +325,7 @@ export class RawWorkerd {
295
325
  }
296
326
  })();
297
327
  try {
298
- const host = new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
328
+ const host = new RawWorkerd(child, await withStartTimeout(Promise.race([ready, exited]), "workerd"), compatibilityDate);
299
329
  starting = false;
300
330
  early = "";
301
331
  return host;
@@ -360,7 +390,7 @@ export class WorkerdTarget {
360
390
  let notice;
361
391
  if (project !== null) {
362
392
  try {
363
- host = await startMiniflare(project, cwd);
393
+ host = await startMiniflare(project, cwd, options.startTimeoutMs);
364
394
  }
365
395
  catch (err) {
366
396
  // Like a broken deno.json (#13): say why in the first cell, and run
@@ -370,16 +400,18 @@ export class WorkerdTarget {
370
400
  }
371
401
  }
372
402
  host ??= await RawWorkerd.start(cwd, options.executable);
373
- return await WorkerdTarget.open(host, cwd, events, "workerd", notice);
403
+ return await WorkerdTarget.open(host, cwd, events, "workerd", { notice, startTimeoutMs: options.startTimeoutMs });
374
404
  }
375
405
  /**
376
406
  * A target over a started host's session. `notice` (a project that
377
407
  * failed to load) is replaced by the bindings when the host has a project.
378
408
  */
379
- static async open(host, cwd, events, runtime, notice) {
409
+ static async open(host, cwd, events, runtime, options = {}) {
380
410
  const target = new WorkerdTarget(host, cwd, events, runtime);
411
+ let notice = options.notice;
381
412
  try {
382
- const { v8, bindings, worker } = await target.#request("info");
413
+ // A driver that never answers is stopped (#336).
414
+ const { v8, bindings, worker } = await withStartTimeout(target.#request("info"), `the ${runtime} driver`, options.startTimeoutMs);
383
415
  if (typeof v8 === "string" && v8)
384
416
  target.versions.engine = `V8 ${v8}`;
385
417
  if (host.project !== undefined) {
@@ -396,7 +428,15 @@ export class WorkerdTarget {
396
428
  return target;
397
429
  }
398
430
  #receive(text) {
399
- const msg = JSON.parse(text);
431
+ const msg = parseJson(text);
432
+ if (msg === undefined) {
433
+ // The driver only sends JSON; a frame that isn't means the session is
434
+ // broken, which ends the target rather than the kernel (#337).
435
+ this.#stopNote = `[cdp-kernel] ${this.runtime} sent a message that isn't JSON, so it was stopped; ` +
436
+ "it restarts on the next cell, without the earlier cells' state";
437
+ this.kill();
438
+ return;
439
+ }
400
440
  if (isRecord(msg) && msg.kind === "stream") {
401
441
  this.#events.stream(msg.name === "stderr" ? "stderr" : "stdout", String(msg.text));
402
442
  }
@@ -519,6 +559,10 @@ export class WorkerdTarget {
519
559
  }
520
560
  return { handled: reply.handled === true };
521
561
  }
562
+ async format(value) {
563
+ const reply = await this.#request("format", { handle: value.objectId });
564
+ return parseBundle(reply.value);
565
+ }
522
566
  async describeError(exception) {
523
567
  if (exception.objectId === undefined)
524
568
  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.1",
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);