@tlockney/cdp-kernel 0.4.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 (66) hide show
  1. package/README.md +78 -478
  2. package/THIRD-PARTY-NOTICES +62 -0
  3. package/dist/bun-target.d.ts +2 -2
  4. package/dist/bun-target.js +30 -8
  5. package/dist/cdp.d.ts +8 -1
  6. package/dist/cdp.js +31 -11
  7. package/dist/celld-target.d.ts +1 -1
  8. package/dist/celld-target.js +54 -19
  9. package/dist/children.d.ts +29 -0
  10. package/dist/children.js +114 -0
  11. package/dist/debugger.d.ts +1 -1
  12. package/dist/debugger.js +21 -10
  13. package/dist/deno-types.d.ts +30 -0
  14. package/dist/deno-types.js +93 -0
  15. package/dist/globals.d.ts +3 -3
  16. package/dist/globals.js +3 -3
  17. package/dist/kernel.d.ts +7 -1
  18. package/dist/kernel.js +348 -68
  19. package/dist/language-service-worker.d.ts +1 -0
  20. package/dist/language-service-worker.js +88 -0
  21. package/dist/language-service.d.ts +64 -0
  22. package/dist/language-service.js +128 -0
  23. package/dist/main.js +102 -8
  24. package/dist/mod.d.ts +6 -3
  25. package/dist/mod.js +6 -3
  26. package/dist/notebook-types.d.ts +62 -0
  27. package/dist/notebook-types.js +310 -0
  28. package/dist/package.d.ts +2 -2
  29. package/dist/package.js +3 -3
  30. package/dist/parent.d.ts +12 -0
  31. package/dist/parent.js +39 -0
  32. package/dist/port-holder.d.ts +13 -0
  33. package/dist/port-holder.js +105 -0
  34. package/dist/sourcemap.d.ts +7 -2
  35. package/dist/sourcemap.js +9 -3
  36. package/dist/strip-esbuild.d.ts +2 -0
  37. package/dist/strip-esbuild.js +44 -0
  38. package/dist/strip-sucrase.d.ts +2 -0
  39. package/dist/strip-sucrase.js +78 -0
  40. package/dist/target.d.ts +58 -6
  41. package/dist/target.js +412 -54
  42. package/dist/text.d.ts +5 -1
  43. package/dist/text.js +55 -2
  44. package/dist/transform.d.ts +30 -1
  45. package/dist/transform.js +68 -44
  46. package/dist/wire.d.ts +8 -1
  47. package/dist/wire.js +3 -1
  48. package/dist/workerd-target.d.ts +7 -1
  49. package/dist/workerd-target.js +102 -8
  50. package/dist/workers-project.js +13 -2
  51. package/dist/ws-client.d.ts +5 -1
  52. package/dist/ws-client.js +46 -13
  53. package/dist/zmtp.d.ts +9 -1
  54. package/dist/zmtp.js +42 -1
  55. package/package.json +17 -6
  56. package/runtime/bootstrap.mjs +233 -42
  57. package/runtime/bun-channel.d.mts +3 -0
  58. package/runtime/bun-channel.mjs +2 -2
  59. package/runtime/comm.mjs +11 -1
  60. package/runtime/deno-compat.d.mts +2 -0
  61. package/runtime/deno-compat.mjs +14 -1
  62. package/runtime/display.mjs +8 -1
  63. package/runtime/introspect.mjs +1 -1
  64. package/runtime/node-hooks.d.mts +48 -0
  65. package/runtime/node-hooks.mjs +257 -41
  66. package/runtime/workerd-worker.mjs +77 -6
@@ -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,10 +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
58
- binding(JSON.stringify({ ...payload, barrier: barrier(true) }));
69
+ if (isBun ? typeof globalThis.__cdpk_send !== "function" : !attached) return; // host not attached yet
70
+ // Serialize before writing the marker: if the payload can't be
71
+ // serialized (a BigInt, a cycle), the error reaches the cell without a
72
+ // gated marker the host would wait on forever (#129). `payload` always
73
+ // has a `kind`, so `body` is never "{}".
74
+ const body = JSON.stringify(payload);
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)}`);
59
82
  }
60
83
 
61
84
  const jupyter = createJupyter({
@@ -69,9 +92,16 @@ const jupyter = createJupyter({
69
92
  // stdin for its JSON-line reply.
70
93
  input(prompt, password) {
71
94
  send({ kind: "input_request", prompt, password });
72
- const line = readStdinLine();
95
+ const line = readInputReply();
73
96
  if (line === null) return null;
74
97
  const reply = JSON.parse(line);
98
+ if (reply.interrupted === true) {
99
+ // Interrupted while waiting for the answer: unwind the cell, as
100
+ // KeyboardInterrupt does in Python's input() (#121).
101
+ const err = new Error("Execution interrupted");
102
+ err.name = "Interrupted";
103
+ throw err;
104
+ }
75
105
  return typeof reply.value === "string" ? reply.value : null;
76
106
  },
77
107
  });
@@ -131,9 +161,37 @@ function bunResolve(specifier) {
131
161
  return Bun.resolveSync(specifier, cwd);
132
162
  }
133
163
 
164
+ // The inspector holds a promise it awaits (callFunctionOn with awaitPromise)
165
+ // only weakly until its then-callback runs, so a GC after the promise
166
+ // settles but before that callback fails the call with "Promise was
167
+ // collected" (#55). hold() keeps the promise until a macrotask after it
168
+ // settles, by which time the inspector's callback has run.
169
+ const held = new Set();
170
+ function hold(value) {
171
+ const promise = Promise.resolve(value);
172
+ held.add(promise);
173
+ const release = () => setTimeout(() => held.delete(promise), 0);
174
+ promise.then(release, release);
175
+ return promise;
176
+ }
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
+
186
+ // Adds the cause to an error from a Deno member the --deno-compat shim
187
+ // lacks (#172); the identity everywhere else.
188
+ let explainError = (text) => text;
189
+
134
190
  globalThis.__cdpk = {
191
+ // The host calls this once it reads message records (Node and Deno).
192
+ attach: () => void (attached = true),
135
193
  // Cell code awaits this first; see PRELUDE in src/transform.ts.
136
- tick: () => new Promise((resolve) => setTimeout(resolve, 0)),
194
+ tick: yieldTask,
137
195
  // Node: the resolve hook re-parents these imports. Deno would resolve
138
196
  // relative paths against this file, so denoImport does it here.
139
197
  import: (specifier, options) =>
@@ -149,6 +207,7 @@ globalThis.__cdpk = {
149
207
  resolve: (specifier) => new URL(specifier, replUrl).href,
150
208
  },
151
209
  barrier,
210
+ hold,
152
211
  // inspect_request (#10). CDP targets find the value with
153
212
  // throwOnSideEffect and describe it here; Bun resolves the path here
154
213
  // too, by descriptor, so nothing runs.
@@ -165,13 +224,24 @@ globalThis.__cdpk = {
165
224
  // Frontend comm messages (#9), from the host's shell channel. Handlers
166
225
  // run after one macrotask, like cells, so interrupt can reach them.
167
226
  async commDispatch(msgType, content, metadata, buffers64) {
168
- await new Promise((resolve) => setTimeout(resolve, 0));
227
+ await yieldTask();
169
228
  const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
170
229
  return await jupyter.commDispatch(msgType, content, metadata, buffers);
171
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
+ },
172
240
  describeError(value) {
173
241
  if (value instanceof Error) {
174
- return JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? "" });
242
+ const message = explainError(value.message);
243
+ const stack = stackOf(value).replace(value.message, () => message);
244
+ return JSON.stringify({ name: value.name, message, stack });
175
245
  }
176
246
  return JSON.stringify({ name: "Error", message: inspect(value), stack: "" });
177
247
  },
@@ -184,26 +254,48 @@ globalThis.confirm = jupyter.confirm;
184
254
  if (isDeno) {
185
255
  // Existing Deno notebooks use Deno.jupyter, but outside `deno jupyter`
186
256
  // that is a non-configurable getter that throws. The global binding
187
- // itself is configurable, so swap in a proxy that answers `jupyter` and
188
- // forwards everything else to the real namespace.
257
+ // itself is configurable, so swap in an object with its own `jupyter`
258
+ // that inherits everything else from the real namespace. Not a Proxy:
259
+ // completion can't list a Proxy's properties, and Shift-Tab's
260
+ // side-effect-free evaluation can't read through one (#132). So
261
+ // Object.keys(Deno) and console.log(Deno) show only `jupyter`, and
262
+ // Deno's functions are called with `this` set to this object (they
263
+ // don't use it).
189
264
  const real = globalThis.Deno;
190
265
  const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Deno");
191
- const proxy = new Proxy(real, {
192
- get: (target, key) => (key === "jupyter" ? jupyter.namespace : Reflect.get(target, key, target)),
266
+ const shim = Object.create(real, {
267
+ jupyter: { value: jupyter.namespace, enumerable: true, configurable: true, writable: true },
193
268
  });
194
- Object.defineProperty(globalThis, "Deno", { ...descriptor, value: proxy });
269
+ Object.defineProperty(globalThis, "Deno", { ...descriptor, value: shim });
195
270
  } else if (process.env.CDPK_DENO_COMPAT === "1" && globalThis.Deno === undefined) {
196
271
  // Opt-in (install --deno-compat): a Deno namespace for notebooks written
197
272
  // against `deno jupyter`. See runtime/deno-compat.mjs for the trade-off.
198
273
  // Absolute URL: the resolve hook re-parents this module's relative
199
274
  // imports to the notebook directory.
200
- const { createDenoShim } = await import(new URL("./deno-compat.mjs", import.meta.url).href);
201
- globalThis.Deno = createDenoShim({ jupyter: jupyter.namespace });
275
+ const { createDenoShim, explainMissing } = await import(new URL("./deno-compat.mjs", import.meta.url).href);
276
+ const shim = createDenoShim({ jupyter: jupyter.namespace });
277
+ globalThis.Deno = shim;
278
+ explainError = (text) => explainMissing(text, shim);
279
+ }
280
+
281
+ /**
282
+ * An error's stack, with its first line naming the error. Bun now and then
283
+ * (about one throw in twenty, #216) records a stack whose first line is a
284
+ * bare "Error", though name and message are set; the frames are right, so
285
+ * only the header is rebuilt.
286
+ */
287
+ function stackOf(err) {
288
+ const stack = err.stack;
289
+ if (typeof stack !== "string") return "";
290
+ const nl = stack.indexOf("\n");
291
+ const first = nl === -1 ? stack : stack.slice(0, nl);
292
+ if (first !== "Error" || !err.message) return stack;
293
+ return `${err.name}: ${err.message}${nl === -1 ? "" : stack.slice(nl)}`;
202
294
  }
203
295
 
204
296
  // An error thrown later by a cell's timer or promise must not kill the kernel.
205
297
  function reportUncaught(kind, err) {
206
- writeErr(`${kind}: ${err instanceof Error ? (err.stack ?? err.message) : inspect(err)}\n`);
298
+ writeErr(`${kind}: ${explainError(err instanceof Error ? (stackOf(err) || err.message) : inspect(err))}\n`);
207
299
  }
208
300
  if (isDeno) {
209
301
  globalThis.addEventListener("unhandledrejection", (e) => {
@@ -222,6 +314,29 @@ if (isDeno) {
222
314
  // Keep the event loop alive between cells.
223
315
  setInterval(() => {}, 1 << 30);
224
316
 
317
+ // A host killed by SIGKILL can't stop its runtime, and nothing visible to
318
+ // JS here changes when its inspector sessions drop, so watch for the host
319
+ // itself going: reparented, or no longer there (#219). Bun exits on its
320
+ // channel's EOF instead.
321
+ if (!isBun) {
322
+ // Held here, so a cell that reassigns them can't break the watch.
323
+ const proc = process;
324
+ const exit = process.exit.bind(process);
325
+ const host = proc.ppid;
326
+ const hostGone = () => {
327
+ if (proc.ppid !== host) return true;
328
+ try {
329
+ proc.kill(host, 0);
330
+ return false;
331
+ } catch (err) {
332
+ return err?.code === "ESRCH";
333
+ }
334
+ };
335
+ setInterval(() => {
336
+ if (hostGone()) exit(1);
337
+ }, 2000);
338
+ }
339
+
225
340
  if (isBun) {
226
341
  const { startChannel } = await import("./bun-channel.mjs");
227
342
  const { createHandler } = await import("./evaluator.mjs");
@@ -230,47 +345,123 @@ if (isBun) {
230
345
  });
231
346
  }
232
347
 
348
+ function writeAllSync(stream, bytes) {
349
+ while (bytes.length > 0) bytes = bytes.subarray(stream.writeSync(bytes));
350
+ }
351
+
233
352
  async function platform() {
234
353
  if (isDeno) {
235
- const stdinBuf = new Uint8Array(1);
236
354
  return {
237
355
  inspect: (v) => Deno.inspect(v, { colors: true, depth: 4 }),
238
356
  inspectPlain: (v) => Deno.inspect(v, { colors: false, depth: 2 }),
239
357
  readFile: (p) => Deno.readFileSync(p),
240
- writeOut: (s) => Deno.stdout.writeSync(ENC.encode(s)),
241
- writeErr: (s) => Deno.stderr.writeSync(ENC.encode(s)),
242
- 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,
243
362
  };
244
363
  }
245
364
  const util = await import("node:util");
246
365
  const fs = await import("node:fs");
247
- const buf = Buffer.alloc(1);
366
+ // Node writes to a pipe without blocking and queues what doesn't fit for
367
+ // the event loop to flush. input() blocks the main thread in readSync, so
368
+ // queued output, and the input_request's barrier marker after it, would
369
+ // never reach the host (#131). Blocking writes finish before readSync
370
+ // starts, as Node already does on Windows. Bun has no _handle and needs
371
+ // its own fix (#180).
372
+ if (isBun) await syncBunOutput();
373
+ else for (const stream of [process.stdout, process.stderr]) stream._handle?.setBlocking?.(true);
248
374
  return {
249
375
  inspect: (v) => util.inspect(v, { colors: true, depth: 4 }),
250
376
  inspectPlain: (v) => util.inspect(v, { colors: false, depth: 2 }),
251
377
  readFile: (p) => fs.readFileSync(p),
252
- writeOut: (s) => process.stdout.write(s),
253
- writeErr: (s) => process.stderr.write(s),
254
- readStdinLine: () =>
255
- readLine(() => {
256
- for (;;) {
257
- try {
258
- return fs.readSync(0, buf, 0, 1, null) === 1 ? buf[0] : null;
259
- } catch (err) {
260
- if (err.code !== "EAGAIN") throw err;
261
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
262
- }
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);
263
389
  }
264
- }),
390
+ }
391
+ },
265
392
  };
266
393
  }
267
394
 
268
- function readLine(nextByte) {
395
+ // Bun makes stdout and stderr non-blocking once process.stdout or
396
+ // process.stderr exists, and then queues what doesn't fit in the pipe for
397
+ // its event loop, console.log's output included. input() blocks the main
398
+ // thread in readSync, so queued output, and the input_request's barrier
399
+ // marker after it, would never reach the host (#180). Here every write
400
+ // finishes before it returns: process.stdout.write and process.stderr.write
401
+ // write through, and console is Node's Console over them, so console output
402
+ // is formatted as on Node.
403
+ async function syncBunOutput() {
404
+ const { writeAll } = await import("./bun-channel.mjs");
405
+ const { Console } = await import("node:console");
406
+ for (const [stream, fd] of [[process.stdout, 1], [process.stderr, 2]]) {
407
+ stream.write = (chunk, encoding, callback) => {
408
+ if (typeof encoding === "function") [callback, encoding] = [encoding, undefined];
409
+ writeAll(fd, typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk);
410
+ if (callback) process.nextTick(callback);
411
+ return true;
412
+ };
413
+ }
414
+ const sync = new Console({ stdout: process.stdout, stderr: process.stderr, colorMode: true });
415
+ for (const key of Object.keys(console)) {
416
+ if (typeof sync[key] === "function") console[key] = sync[key].bind(sync);
417
+ }
418
+ }
419
+
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() {
269
437
  const bytes = [];
270
438
  for (;;) {
271
- const byte = nextByte();
439
+ const byte = readExactly(1)?.[0] ?? null;
272
440
  if (byte === null) return bytes.length ? new TextDecoder().decode(new Uint8Array(bytes)) : null;
273
441
  if (byte === 0x0a) return new TextDecoder().decode(new Uint8Array(bytes));
274
442
  bytes.push(byte);
275
443
  }
276
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
+ }
@@ -1,3 +1,6 @@
1
1
  import type { Handler } from "./evaluator.mjs";
2
2
 
3
3
  export function startChannel(handle: Handler, ready: { [key: string]: unknown }, fd?: number): void;
4
+
5
+ /** Writes all of `text` to `fd` before returning, even if the fd is non-blocking. */
6
+ export function writeAll(fd: number, text: string | Uint8Array): void;
@@ -14,8 +14,8 @@ import fs from "node:fs";
14
14
  const CHANNEL_FD = 3;
15
15
 
16
16
  /** Synchronous, like a CDP binding call, so output order is kept. */
17
- function writeAll(fd, text) {
18
- const bytes = Buffer.from(text);
17
+ export function writeAll(fd, text) {
18
+ const bytes = typeof text === "string" ? Buffer.from(text) : Buffer.from(text.buffer, text.byteOffset, text.byteLength);
19
19
  for (let offset = 0; offset < bytes.length;) {
20
20
  try {
21
21
  offset += fs.writeSync(fd, bytes, offset);
package/runtime/comm.mjs CHANGED
@@ -89,7 +89,17 @@ export function createComms(send, newId = () => crypto.randomUUID()) {
89
89
  if (msgType === "comm_open") {
90
90
  const handler = targets.get(content.target_name);
91
91
  if (!handler) return false;
92
- await handler(track(content.comm_id, content.target_name), msg);
92
+ const comm = track(content.comm_id, content.target_name);
93
+ try {
94
+ await handler(comm, msg);
95
+ } catch (err) {
96
+ // The host answers a failed comm_open with comm_close, so the comm
97
+ // is gone on the frontend: drop it here too, without running close
98
+ // handlers or sending anything (#137).
99
+ comm.closed = true;
100
+ forget(comm.id);
101
+ throw err;
102
+ }
93
103
  return true;
94
104
  }
95
105
  const comm = comms.get(content.comm_id);
@@ -114,3 +114,5 @@ export interface DenoShim {
114
114
 
115
115
  export function denoError<E>(err: E): E | NotFound | AlreadyExists | PermissionDenied | IsADirectory | NotADirectory;
116
116
  export function createDenoShim(options: { jupyter: unknown }): DenoShim;
117
+ /** Adds the cause to "Deno.x is not a function" when `shim` lacks `x`. */
118
+ export function explainMissing(message: string, shim: object): string;
@@ -7,7 +7,8 @@
7
7
  // shim installed they take their Deno code paths, which may call APIs not
8
8
  // covered here (Deno.serve, Deno.Command, Deno.openKv, permissions...).
9
9
  // Uncovered APIs are simply absent rather than throwing on access, so
10
- // feature checks like `typeof Deno.serve === "function"` still work.
10
+ // feature checks like `typeof Deno.serve === "function"` still work. The
11
+ // error from calling one says so instead (explainMissing, #172).
11
12
 
12
13
  import fs from "node:fs";
13
14
  import fsp from "node:fs/promises";
@@ -100,6 +101,18 @@ const writeFlag = (options) => (options?.append ? "a" : options?.createNew ? "wx
100
101
  const OS_NAMES = { darwin: "darwin", linux: "linux", win32: "windows", freebsd: "freebsd" };
101
102
  const ARCH_NAMES = { arm64: "aarch64", x64: "x86_64" };
102
103
 
104
+ const MEMBERS_DOC = "https://cdp-kernel.org/docs/jupyter-api#notebooks-written-for-deno";
105
+
106
+ /**
107
+ * Adds the cause to V8's "Deno.serve is not a function" when `shim` lacks
108
+ * that member (#172), or returns `message` unchanged.
109
+ */
110
+ export function explainMissing(message, shim) {
111
+ return message.replace(/\b(Deno\.([\w$]+)) is not a (?:function|constructor)\b/, (all, expr, name) =>
112
+ name in shim ? all : `${all}: ${expr} is not implemented by cdp-kernel's --deno-compat namespace (see ${MEMBERS_DOC})`
113
+ );
114
+ }
115
+
103
116
  export function createDenoShim({ jupyter }) {
104
117
  return {
105
118
  jupyter,
@@ -180,7 +180,14 @@ export function createJupyter(host) {
180
180
  }
181
181
 
182
182
  function image(obj) {
183
- if (typeof obj === "string") obj = host.readFile(obj);
183
+ if (typeof obj === "string") {
184
+ // As Deno does: a path that can't be read gets the TypeError below.
185
+ try {
186
+ obj = host.readFile(obj);
187
+ } catch {
188
+ // fall through
189
+ }
190
+ }
184
191
  if (isJpg(obj)) return { [$display]: () => ({ "image/jpeg": host.base64(obj) }) };
185
192
  if (isPng(obj)) return { [$display]: () => ({ "image/png": host.base64(obj) }) };
186
193
  throw new TypeError(
@@ -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)));
@@ -8,12 +8,24 @@ export function parsePackageSpecifier(spec: string): PackageSpecifier;
8
8
  export function jsrToNpm(spec: string): PackageSpecifier;
9
9
  export function aliasFor(name: string, range: string): string;
10
10
  export type Installer = "npm" | "bun";
11
+ /** Where `installer` keeps its packages: $XDG_CACHE_HOME/cdp-kernel/<installer>, or CDPK_CACHE_DIR for npm. */
12
+ export function cacheDirPath(
13
+ installer: Installer,
14
+ env?: { [name: string]: string | undefined },
15
+ home?: string,
16
+ ): string;
11
17
  export function installCommand(
12
18
  installer: Installer,
13
19
  alias: string,
14
20
  name: string,
15
21
  range: string,
16
22
  ): { cmd: string; args: string[]; env?: { [name: string]: string } };
23
+ /** How to spawn an install command; on Windows, npm runs through cmd.exe. */
24
+ export function spawnCommand(
25
+ cmd: string,
26
+ args: string[],
27
+ platform?: string,
28
+ ): { cmd: string; args: string[]; shell: boolean };
17
29
  /** Runs an install; `env` is added to the process environment. */
18
30
  export type InstallRunner = (
19
31
  cmd: string,
@@ -26,6 +38,21 @@ export function ensureInstalled(
26
38
  installer?: Installer,
27
39
  options?: { lockFile?: string | null; run?: InstallRunner },
28
40
  ): { dir: string; alias: string };
41
+ /** ensureInstalled without blocking the event loop; `run` may return a promise. */
42
+ export function ensureInstalledAsync(
43
+ pkg: { name: string; range: string },
44
+ installer?: Installer,
45
+ options?: {
46
+ lockFile?: string | null;
47
+ run?: (...args: Parameters<InstallRunner>) => ReturnType<InstallRunner> | Promise<ReturnType<InstallRunner>>;
48
+ },
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;
29
56
 
30
57
  export const LOCK_FILE: string;
31
58
  export interface Lock {
@@ -36,4 +63,25 @@ export function findLockFile(dir: string): string | null;
36
63
  export function readLock(path: string): Lock;
37
64
  export function writeLock(path: string, lock: Lock): void;
38
65
  export function withInstallLock<T>(dir: string, fn: () => T, options?: { timeoutMs?: number }): T;
66
+ export function withInstallLockAsync<T>(dir: string, fn: () => T | Promise<T>, options?: { timeoutMs?: number }): Promise<T>;
67
+ /** Compiles one TypeScript or JSX file with the esbuild binary at `binary`; `run` is replaceable in tests. */
68
+ export function compileTypeScript(
69
+ source: string,
70
+ filename: string,
71
+ binary: string,
72
+ run?: (cmd: string, args: string[], options: object) => { status: number | null; stdout: string; stderr: string; error?: Error },
73
+ loader?: "ts" | "tsx" | "jsx",
74
+ ): string;
75
+ export interface LoadResult {
76
+ format?: string | null;
77
+ source?: string | ArrayBuffer | ArrayBufferView | null;
78
+ shortCircuit?: boolean;
79
+ }
80
+ /** The load hook: Node's own result unless its type stripping would reject the file; .tsx/.jsx always compiled. */
81
+ export function loadTypeScript(
82
+ url: string,
83
+ context: object,
84
+ nextLoad: (url: string, context: object) => LoadResult,
85
+ binary: string | undefined,
86
+ ): LoadResult;
39
87
  export function installNodeResolveHooks(urls: { shimUrl: string; replUrl: string }): void;