@tlockney/cdp-kernel 0.4.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.
package/dist/target.js CHANGED
@@ -4,17 +4,33 @@
4
4
  import { Buffer } from "node:buffer";
5
5
  import process from "node:process";
6
6
  import { spawn } from "node:child_process";
7
+ import { createRequire } from "node:module";
7
8
  import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
8
10
  import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
9
11
  import { SHIM, transformCell } from "./transform.js";
10
12
  import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
13
+ import { killOnExit } from "./children.js";
11
14
  const BINDING = "__cdpk_send";
15
+ /** How long a target may take from spawn to a usable session. */
16
+ const START_TIMEOUT_MS = 60_000;
12
17
  const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
13
18
  const MAX_MARKER_LEN = 24;
14
19
  // Lines the runtimes print to stderr on their own: inspector notices, and
15
20
  // Deno's npm/jsr "Download https://..." progress (which `deno jupyter`
16
- // sends to the server log rather than the notebook).
17
- const INSPECTOR_CHATTER = /^(?:\x1b\[[0-9;]*m)*(Debugger listening on |Debugger attached\.|For help, see: |Visit chrome:\/\/inspect|Waiting for the debugger to disconnect|Download(?:\x1b\[[0-9;]*m)* https?:\/\/).*\n/gm;
21
+ // sends to the server log rather than the notebook). Each entry is the
22
+ // line's start once colour codes are removed.
23
+ const INSPECTOR_CHATTER = [
24
+ "Debugger listening on ",
25
+ "Debugger attached.",
26
+ "For help, see: https://nodejs.org/",
27
+ "Visit chrome://inspect",
28
+ "Waiting for the debugger to disconnect",
29
+ ];
30
+ const DENO_CHATTER = [...INSPECTOR_CHATTER, "Download http://", "Download https://"];
31
+ const ANSI_SGR = /\x1b\[[0-9;]*m/g;
32
+ /** A colour code cut off at the end of a chunk. */
33
+ const PARTIAL_SGR = /\x1b(?:\[[0-9;]*)?$/;
18
34
  /** `bootstrap` is the local path of runtime/bootstrap.mjs (see runtimeDir). */
19
35
  export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {}) {
20
36
  // The CDP WebSocket has no authentication, so its URL must stay private:
@@ -41,6 +57,33 @@ export function launchCommand(runtime, bootstrap, { executable, denoConfig } = {
41
57
  const node = hostRuntime() === "node" ? process.execPath : "node";
42
58
  return { cmd: executable ?? node, args: [...inspect, bootstrap] };
43
59
  }
60
+ /**
61
+ * The esbuild binary this host's esbuild runs, for a Node target to compile
62
+ * imported TypeScript Node can't strip (#73); null when it can't be found.
63
+ * Resolved from esbuild's own location, so it's found in a node_modules
64
+ * install and in Deno's npm cache alike.
65
+ */
66
+ export function esbuildBinary(resolve = import.meta.resolve, platform = process.platform, arch = process.arch) {
67
+ if (process.env.ESBUILD_BINARY_PATH)
68
+ return process.env.ESBUILD_BINARY_PATH;
69
+ const subpath = platform === "win32" ? "esbuild.exe" : "bin/esbuild";
70
+ try {
71
+ const main = fileURLToPath(resolve("esbuild"));
72
+ return createRequire(main).resolve(`@esbuild/${platform}-${arch}/${subpath}`);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /** The parsed value, or undefined for text that isn't JSON. */
79
+ export function parseJson(text) {
80
+ try {
81
+ return JSON.parse(text);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
44
87
  export function isTargetMessage(value) {
45
88
  if (typeof value !== "object" || value === null)
46
89
  return false;
@@ -139,6 +182,66 @@ export class PipeDemux {
139
182
  void this.#pump();
140
183
  }
141
184
  }
185
+ /**
186
+ * Removes the runtime's own stderr lines (see INSPECTOR_CHATTER) and passes
187
+ * everything else on. A chatter line can arrive split across chunks, so a
188
+ * trailing partial line that could still turn out to be one is held back
189
+ * until its newline arrives; any other partial line passes straight through.
190
+ */
191
+ export class StderrFilter {
192
+ #starts;
193
+ #emit;
194
+ #held = "";
195
+ #atLineStart = true;
196
+ constructor(runtime, emit) {
197
+ this.#starts = runtime === "deno" ? DENO_CHATTER : INSPECTOR_CHATTER;
198
+ this.#emit = emit;
199
+ }
200
+ push(chunk) {
201
+ let text = this.#held + chunk;
202
+ this.#held = "";
203
+ let out = "";
204
+ if (!this.#atLineStart) {
205
+ // The rest of a line that already went out unfiltered.
206
+ const nl = text.indexOf("\n");
207
+ if (nl === -1) {
208
+ this.#emit(text);
209
+ return;
210
+ }
211
+ out = text.slice(0, nl + 1);
212
+ text = text.slice(nl + 1);
213
+ }
214
+ const lines = text.split("\n");
215
+ const last = lines.pop();
216
+ for (const line of lines)
217
+ if (!this.#isChatter(line, false))
218
+ out += line + "\n";
219
+ if (last && this.#isChatter(last, true))
220
+ this.#held = last;
221
+ else
222
+ out += last;
223
+ this.#atLineStart = last === "" || this.#held !== "";
224
+ if (out)
225
+ this.#emit(out);
226
+ }
227
+ /** Releases a held partial line (the process died). */
228
+ flush() {
229
+ if (this.#held)
230
+ this.#emit(this.#held);
231
+ this.#held = "";
232
+ this.#atLineStart = true;
233
+ }
234
+ /** Whether `line` is chatter or, when `partial`, could still become chatter. */
235
+ #isChatter(line, partial) {
236
+ let plain = line.replace(ANSI_SGR, "");
237
+ if (partial)
238
+ plain = plain.replace(PARTIAL_SGR, "");
239
+ // Any other escape may be a PipeDemux marker, which must never be dropped or held.
240
+ if (plain.includes("\x1b"))
241
+ return false;
242
+ return this.#starts.some((start) => plain.startsWith(start) || (partial && start.startsWith(plain)));
243
+ }
244
+ }
142
245
  /** Promises keyed by barrier number, resolvable before or after first use. */
143
246
  export class Gates {
144
247
  #map = new Map();
@@ -167,6 +270,12 @@ export class Gates {
167
270
  export class Interrupted extends Error {
168
271
  name = "Interrupted";
169
272
  }
273
+ /** A catch handler: a call interrupt() abandoned becomes Interrupted. */
274
+ function interrupted(what) {
275
+ return (err) => {
276
+ throw err instanceof CdpAbandoned ? new Interrupted(`${what} interrupted`) : err;
277
+ };
278
+ }
170
279
  export class Target {
171
280
  runtime;
172
281
  transform = transformCell;
@@ -180,11 +289,20 @@ export class Target {
180
289
  #messages = Promise.resolve();
181
290
  #interrupted = false;
182
291
  #paused = false;
292
+ /** Set once the main context is gone: the runtime is exiting (#130). */
293
+ #exiting = false;
294
+ #exited;
183
295
  #baselineGlobals = new Set();
184
296
  versions;
185
297
  constructor(runtime, child, shell, control, stdout, stderr, gates, contextId, versions) {
186
298
  this.runtime = runtime;
187
299
  this.#child = child;
300
+ this.#exited = new Promise((resolve) => {
301
+ if (child.exitCode !== null || child.signalCode !== null)
302
+ resolve();
303
+ else
304
+ child.once("exit", () => resolve());
305
+ });
188
306
  this.#shell = shell;
189
307
  this.#control = control;
190
308
  this.#stdout = stdout;
@@ -208,18 +326,41 @@ export class Target {
208
326
  env.CDPK_DENO_COMPAT = "1";
209
327
  if (runtime === "deno" && options.denoConfig !== undefined)
210
328
  env.CDPK_DENO_PROJECT = "1";
329
+ const esbuild = runtime === "node" ? esbuildBinary() : null;
330
+ if (esbuild !== null)
331
+ env.CDPK_ESBUILD_BINARY = esbuild;
211
332
  delete env.NO_COLOR;
212
333
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
334
+ killOnExit(child);
335
+ // A start that fails or stalls must not leave the runtime running: its
336
+ // pipes and inspector sockets would keep this process alive (#52).
337
+ const sessions = [];
338
+ const timeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
339
+ let timer;
340
+ const stalled = new Promise((_, reject) => {
341
+ timer = setTimeout(() => reject(new Error(`${cmd} did not start within ${timeoutMs / 1000}s`)), timeoutMs);
342
+ });
343
+ try {
344
+ return await Promise.race([Target.#start(runtime, events, cmd, child, sessions), stalled]);
345
+ }
346
+ catch (error) {
347
+ for (const session of sessions)
348
+ session.close();
349
+ child.kill();
350
+ throw error;
351
+ }
352
+ finally {
353
+ clearTimeout(timer);
354
+ }
355
+ }
356
+ static async #start(runtime, events, cmd, child, sessions) {
213
357
  child.stdout.setEncoding("utf8");
214
358
  child.stderr.setEncoding("utf8");
215
359
  const gates = new Gates();
216
360
  const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n));
217
361
  const stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => gates.wait(n));
218
- const pushStderr = (chunk) => {
219
- const text = chunk.replace(INSPECTOR_CHATTER, "");
220
- if (text)
221
- stderr.push(text);
222
- };
362
+ const filter = new StderrFilter(runtime, (text) => stderr.push(text));
363
+ const pushStderr = (chunk) => filter.push(chunk);
223
364
  // Until the inspector URL appears, stderr carries the runtime's banner.
224
365
  const wsUrl = await new Promise((resolve, reject) => {
225
366
  let banner = "";
@@ -240,12 +381,15 @@ export class Target {
240
381
  child.stdout.on("data", (c) => stdout.push(c));
241
382
  child.once("exit", (code) => {
242
383
  gates.openAll();
384
+ filter.flush();
243
385
  stdout.close();
244
386
  stderr.close();
245
387
  events.exit(code);
246
388
  });
247
389
  const shell = await CdpSession.connect(wsUrl);
390
+ sessions.push(shell);
248
391
  const control = await CdpSession.connect(wsUrl);
392
+ sessions.push(control);
249
393
  const contextReady = new Promise((resolve) => {
250
394
  shell.on("Runtime.executionContextCreated", (params) => {
251
395
  const context = params.context;
@@ -257,21 +401,33 @@ export class Target {
257
401
  await shell.send("Runtime.addBinding", { name: BINDING });
258
402
  const contextId = await contextReady;
259
403
  const target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
404
+ // process.exit() or Deno.exit() destroys the context, then the runtime
405
+ // waits for its inspector clients to disconnect before it exits. Let go,
406
+ // so it exits and the kernel starts a fresh one (#130).
407
+ shell.on("Runtime.executionContextDestroyed", (params) => {
408
+ if (params.executionContextId === contextId)
409
+ target.#onExit();
410
+ });
260
411
  shell.on("Runtime.bindingCalled", (params) => {
261
412
  if (params.name !== BINDING || typeof params.payload !== "string")
262
413
  return;
263
- const msg = JSON.parse(params.payload);
414
+ // __cdpk_send is an ordinary global, so a cell can call it with
415
+ // anything; that mustn't take the host down (#122).
416
+ const msg = parseJson(params.payload);
264
417
  if (isTargetMessage(msg))
265
418
  target.#deliver(msg, events);
266
419
  });
267
- // Wait for the bootstrap's top-level await to finish installing the shim.
268
- for (let i = 0; i < 200; i++) {
269
- const probe = await target.#evaluateRaw(`typeof globalThis.${SHIM}?.tick === "function"`);
420
+ // Wait for the bootstrap's top-level await to finish installing the
421
+ // shim. There is no cap here: launch's start timeout bounds the wait and
422
+ // kills the child, so a slow bootstrap never yields a target without
423
+ // the shim (#134).
424
+ for (;;) {
425
+ const probe = await target.#evaluateSync(`typeof globalThis.${SHIM}?.tick === "function"`);
270
426
  if (probe.result.value === true)
271
427
  break;
272
428
  await new Promise((r) => setTimeout(r, 25));
273
429
  }
274
- const versions = await target.#evaluateRaw(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, engine: "V8 " + Deno.version.v8 } : { runtime: process.versions.node, engine: "V8 " + process.versions.v8 })`);
430
+ const versions = await target.#evaluateSync(`JSON.stringify(typeof Deno !== "undefined" ? { runtime: Deno.version.deno, engine: "V8 " + Deno.version.v8 } : { runtime: process.versions.node, engine: "V8 " + process.versions.v8 })`);
275
431
  Object.assign(target.versions, JSON.parse(String(versions.result.value)));
276
432
  // The runtime's own enumerable globals, before any cell: the debugger's
277
433
  // variable views show only what cells added (#18).
@@ -304,11 +460,23 @@ export class Target {
304
460
  }
305
461
  /** Resolves once all output and messages sent so far have been delivered. */
306
462
  async flush() {
307
- const n = await this.#evaluateRaw(`${SHIM}.barrier()`);
463
+ const n = await this.#evaluateSync(`${SHIM}.barrier()`).catch(interrupted("Execution"));
308
464
  const barrier = Number(n.result.value);
309
465
  await Promise.all([this.#stdout.waitFor(barrier), this.#stderr.waitFor(barrier)]);
310
466
  await this.#messages;
311
467
  }
468
+ /**
469
+ * For expressions that return a value without awaiting. With replMode or
470
+ * awaitPromise, the inspector holds the result promise only weakly until
471
+ * its then-callback runs, even when the expression returns a plain value:
472
+ * a GC in that window fails the call with "Promise was collected" (#50).
473
+ * A plain evaluate returns the value directly.
474
+ */
475
+ #evaluateSync(expression) {
476
+ return this.#shell
477
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, returnByValue: true })
478
+ .then(asEvaluateResult);
479
+ }
312
480
  #evaluateRaw(expression, session = this.#shell) {
313
481
  return session
314
482
  .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, awaitPromise: true })
@@ -323,9 +491,27 @@ export class Target {
323
491
  catch (err) {
324
492
  if (err instanceof CdpAbandoned || this.#interrupted)
325
493
  throw new Interrupted("Execution interrupted");
494
+ if (this.#exiting) {
495
+ // The child's exit, which drops this target in the kernel, comes
496
+ // first, so the next cell starts a fresh one.
497
+ await this.#exited;
498
+ throw new Error(`${this.runtime} exited`);
499
+ }
326
500
  throw err;
327
501
  }
328
502
  }
503
+ #onExit() {
504
+ if (this.#exiting)
505
+ return;
506
+ this.#exiting = true;
507
+ this.#shell.close();
508
+ this.#control.close();
509
+ // In case it still doesn't exit once both sessions are gone.
510
+ setTimeout(() => {
511
+ if (this.#child.exitCode === null && this.#child.signalCode === null)
512
+ this.#child.kill("SIGKILL");
513
+ }, 2000).unref();
514
+ }
329
515
  /**
330
516
  * Deno calls IsolateHandle::terminate_execution from another thread. The
331
517
  * CDP equivalent is Runtime.terminateExecution, sent on a second session
@@ -341,11 +527,27 @@ export class Target {
341
527
  await this.#control.send("Runtime.terminateExecution");
342
528
  this.#shell.abandonPending();
343
529
  }
530
+ /**
531
+ * For synchronous functions. With awaitPromise the inspector would wrap
532
+ * even a plain return value in a promise it holds only weakly (#55).
533
+ */
344
534
  callFunctionOn(functionDeclaration, args) {
345
535
  return this.#shell.send("Runtime.callFunctionOn", {
346
536
  functionDeclaration,
347
537
  arguments: args,
348
538
  executionContextId: this.#contextId,
539
+ returnByValue: true,
540
+ });
541
+ }
542
+ /**
543
+ * For functions that return a promise. The target holds it until after
544
+ * the inspector's then-callback has run, so a GC can't collect it first.
545
+ */
546
+ #callAsyncFunctionOn(functionDeclaration, args) {
547
+ return this.#shell.send("Runtime.callFunctionOn", {
548
+ functionDeclaration: `function (...args) { return globalThis.${SHIM}.hold((${functionDeclaration}).apply(this, args)); }`,
549
+ arguments: args,
550
+ executionContextId: this.#contextId,
349
551
  awaitPromise: true,
350
552
  returnByValue: true,
351
553
  });
@@ -357,10 +559,12 @@ export class Target {
357
559
  : result.unserializableValue !== undefined
358
560
  ? { unserializableValue: result.unserializableValue }
359
561
  : { value: result.value };
360
- await this.callFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
562
+ // An async $display method can still be running when interrupt()
563
+ // abandons the call (#135).
564
+ await this.#callAsyncFunctionOn(`function (n, r) { return globalThis.${SHIM}.broadcastResult(n, r); }`, [
361
565
  { value: executionCount },
362
566
  arg,
363
- ]);
567
+ ]).catch(interrupted("Execution"));
364
568
  }
365
569
  /**
366
570
  * Hands a frontend comm message to the target's comms. Resolves to
@@ -371,18 +575,12 @@ export class Target {
371
575
  this.#interrupted = false;
372
576
  let res;
373
577
  try {
374
- res = await this.#shell.send("Runtime.callFunctionOn", {
375
- functionDeclaration: `function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`,
376
- arguments: [
377
- { value: msgType },
378
- { value: content },
379
- { value: metadata },
380
- { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
381
- ],
382
- executionContextId: this.#contextId,
383
- awaitPromise: true,
384
- returnByValue: true,
385
- });
578
+ res = await this.#callAsyncFunctionOn(`function (t, c, m, b) { return globalThis.${SHIM}.commDispatch(t, c, m, b); }`, [
579
+ { value: msgType },
580
+ { value: content },
581
+ { value: metadata },
582
+ { value: buffers.map((b) => Buffer.from(b).toString("base64")) },
583
+ ]);
386
584
  }
387
585
  catch (err) {
388
586
  if (err instanceof CdpAbandoned || this.#interrupted)
@@ -407,12 +605,15 @@ export class Target {
407
605
  /**
408
606
  * Evaluates without side effects: V8 throws instead of running anything
409
607
  * that would change state (a call, a getter that writes), so completing
410
- * or inspecting `obj.` never runs user code (#10).
608
+ * or inspecting `obj.` never runs user code (#10). Not replMode, which
609
+ * returns a promise the inspector holds only weakly (#55); a plain
610
+ * evaluate sees the cells' let and const too.
411
611
  */
412
612
  #evaluatePure(expression) {
413
613
  return this.#shell
414
- .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, throwOnSideEffect: true })
415
- .then(asEvaluateResult, () => null);
614
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, throwOnSideEffect: true })
615
+ .then(asEvaluateResult)
616
+ .catch(() => null);
416
617
  }
417
618
  async propertyNames(expression) {
418
619
  const evaluated = await this.#evaluatePure(`(${expression})`);
@@ -444,8 +645,8 @@ export class Target {
444
645
  const res = await this.#shell.send("Runtime.globalLexicalScopeNames", { executionContextId: this.#contextId });
445
646
  return res.names ?? [];
446
647
  }
447
- replyInput(value) {
448
- this.#child.stdin.write(JSON.stringify({ value }) + "\n");
648
+ replyInput(value, interrupted = false) {
649
+ this.#child.stdin.write(JSON.stringify({ value, interrupted }) + "\n");
449
650
  }
450
651
  kill() {
451
652
  this.#shell.close();
package/dist/text.d.ts CHANGED
@@ -6,7 +6,11 @@ export type IsCompleteReply = {
6
6
  } | {
7
7
  status: "invalid";
8
8
  };
9
- /** Bracket/quote/comment scanner deciding whether a cell is finished. */
9
+ /**
10
+ * Bracket/quote/comment scanner deciding whether a cell is finished. Regex
11
+ * literals are recognised heuristically (see startsRegex), and a template's
12
+ * `${...}` is skipped like the rest of the template.
13
+ */
10
14
  export declare function checkIsComplete(code: string): IsCompleteReply;
11
15
  /** The dotted expression ending at the cursor, e.g. "Math.fl". */
12
16
  export declare function exprAtCursor(code: string, cursor: number): string;
package/dist/text.js CHANGED
@@ -2,7 +2,53 @@
2
2
  //
3
3
  // Ported from Deno's cli/js/jupyter_kernel.js (v2.9.6), which is
4
4
  // Copyright 2018-2026 the Deno authors, MIT license.
5
- /** Bracket/quote/comment scanner deciding whether a cell is finished. */
5
+ // Keywords after which a `/` starts a regex literal rather than dividing.
6
+ const REGEX_AFTER_WORD = new Set([
7
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw",
8
+ "case", "do", "else", "yield", "await",
9
+ ]);
10
+ /**
11
+ * Whether a `/` at `i` (not starting a comment) begins a regex literal: the
12
+ * usual heuristic, true unless the previous token can end an operand (a
13
+ * name, number, closing bracket or string). It can't tell `a++ / b` or a
14
+ * block's `} /re/` apart; those are rare enough in a console.
15
+ */
16
+ function startsRegex(code, i) {
17
+ let j = i - 1;
18
+ while (j >= 0 && /\s/.test(code[j]))
19
+ j--;
20
+ if (j < 0)
21
+ return true;
22
+ const prev = code[j];
23
+ if (/[\w$]/.test(prev)) {
24
+ const word = /[\w$]+$/.exec(code.slice(0, j + 1))?.[0] ?? "";
25
+ return REGEX_AFTER_WORD.has(word);
26
+ }
27
+ return !/[)\]}'"`]/.test(prev);
28
+ }
29
+ /** The index just past a regex literal starting at `i`, or -1 if it doesn't end on its line. */
30
+ function regexEnd(code, i) {
31
+ let inClass = false;
32
+ for (let j = i + 1; j < code.length; j++) {
33
+ const c = code[j];
34
+ if (c === "\n")
35
+ return -1;
36
+ if (c === "\\")
37
+ j++;
38
+ else if (c === "[")
39
+ inClass = true;
40
+ else if (c === "]")
41
+ inClass = false;
42
+ else if (c === "/" && !inClass)
43
+ return j + 1;
44
+ }
45
+ return -1;
46
+ }
47
+ /**
48
+ * Bracket/quote/comment scanner deciding whether a cell is finished. Regex
49
+ * literals are recognised heuristically (see startsRegex), and a template's
50
+ * `${...}` is skipped like the rest of the template.
51
+ */
6
52
  export function checkIsComplete(code) {
7
53
  const stack = [];
8
54
  const closers = { ")": "(", "]": "[", "}": "{" };
@@ -21,11 +67,18 @@ export function checkIsComplete(code) {
21
67
  i = end + 2;
22
68
  continue;
23
69
  }
70
+ if (ch === "/" && startsRegex(code, i)) {
71
+ const end = regexEnd(code, i);
72
+ if (end !== -1) {
73
+ i = end;
74
+ continue;
75
+ }
76
+ }
24
77
  if (ch === "'" || ch === '"' || ch === "`") {
25
78
  i++;
26
79
  let closed = false;
27
80
  while (i < code.length) {
28
- if (code[i] === "\\" && ch !== "`") {
81
+ if (code[i] === "\\") {
29
82
  i += 2;
30
83
  continue;
31
84
  }
@@ -1,5 +1,14 @@
1
1
  import { LineMap } from "./sourcemap.ts";
2
2
  export declare const SHIM = "__cdpk";
3
+ /**
4
+ * Each replMode cell also ends by yielding one macrotask, so the script
5
+ * settles with no other microtasks queued ahead of the inspector's
6
+ * then-callback. Until that callback runs, the inspector holds the result
7
+ * promise only weakly, and a GC in between fails the evaluate with "Promise
8
+ * was collected" (#55). A declaration has no completion value, so the
9
+ * cell's value is unchanged; replMode lets `let` be declared again.
10
+ */
11
+ export declare const SETTLE = "\n;let __cdpk_settled = await __cdpk.tick();";
3
12
  export declare class CellSyntaxError extends Error {
4
13
  name: string;
5
14
  }
package/dist/transform.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // TypeScript/JSX part and acorn finds the module syntax that a script can't
7
7
  // contain:
8
8
  // import x, { y as z } from "m" -> var __cdpk_m0 = await __cdpk.import("m");
9
- // var x = __cdpk_m0.default, z = __cdpk_m0.y;
9
+ // var x = __cdpk_m0["default"], z = __cdpk_m0["y"];
10
10
  // import("m") -> __cdpk.import("m")
11
11
  // import.meta -> __cdpk.meta
12
12
  // export const a = 1 -> const a = 1
@@ -25,6 +25,15 @@ export const SHIM = "__cdpk";
25
25
  // interrupt a busy loop. (Within that dispatch the inspector processes no
26
26
  // other messages, so an interrupt would never be seen.)
27
27
  const PRELUDE = `'use strict'; await ${SHIM}.tick();\n`;
28
+ /**
29
+ * Each replMode cell also ends by yielding one macrotask, so the script
30
+ * settles with no other microtasks queued ahead of the inspector's
31
+ * then-callback. Until that callback runs, the inspector holds the result
32
+ * promise only weakly, and a GC in between fails the evaluate with "Promise
33
+ * was collected" (#55). A declaration has no completion value, so the
34
+ * cell's value is unchanged; replMode lets `let` be declared again.
35
+ */
36
+ export const SETTLE = `\n;let ${SHIM}_settled = await ${SHIM}.tick();`;
28
37
  export class CellSyntaxError extends Error {
29
38
  name = "SyntaxError";
30
39
  }
@@ -34,8 +43,14 @@ function jsxSettings(jsx) {
34
43
  }
35
44
  return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
36
45
  }
46
+ // esbuild compiles `import x = require("m")` to a `require` call, which a
47
+ // cell doesn't have (#143); `import type x = require("m")` is erased.
48
+ const IMPORT_REQUIRE = /^\s*(?:export\s+)?import\s+(?!type\s)[\w$]+\s*=\s*require\s*\(/m;
37
49
  /** esbuild's output, with its source map's `mappings` when asked for. */
38
50
  function stripTypesMapped(code, options, sourcemap) {
51
+ if (IMPORT_REQUIRE.test(code)) {
52
+ throw new CellSyntaxError('import x = require("m") is not supported in a cell; use import x from "m"');
53
+ }
39
54
  const compilerOptions = {
40
55
  verbatimModuleSyntax: true,
41
56
  ...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
@@ -47,6 +62,9 @@ function stripTypesMapped(code, options, sourcemap) {
47
62
  const result = transformSync(code, {
48
63
  loader,
49
64
  target: "esnext",
65
+ // Neither acorn nor V8 parses standard (TC39) decorators or
66
+ // `accessor` yet, so esbuild lowers them (#142).
67
+ supported: { decorators: false },
50
68
  ...jsxSettings(options.jsx),
51
69
  tsconfigRaw: { compilerOptions },
52
70
  logLevel: "silent",
@@ -80,8 +98,9 @@ function rewriteImport(code, node, index) {
80
98
  ? `, { with: { ${node.attributes.map((a) => code.slice(a.start, a.end)).join(", ")} } }`
81
99
  : "";
82
100
  const load = `await ${SHIM}.import(${source}${attrs})`;
101
+ // `void`: as a cell's last statement, the import isn't its result (#139).
83
102
  if (node.specifiers.length === 0)
84
- return `${load};`;
103
+ return `void ${load};`;
85
104
  const ns = `${SHIM}_m${index}`;
86
105
  const bindings = node.specifiers.map((spec) => {
87
106
  const local = String(spec.local.name);
@@ -140,7 +159,13 @@ export function rewriteModuleSyntax(js) {
140
159
  }
141
160
  case "ExportDefaultDeclaration": {
142
161
  const decl = node;
143
- edits.push({ start: decl.start, end: decl.declaration.start, text: "" });
162
+ const anonymous = (decl.declaration.type === "FunctionDeclaration" || decl.declaration.type === "ClassDeclaration") &&
163
+ decl.declaration.id === null;
164
+ // An anonymous `function () {}` or `class {}` isn't a statement on
165
+ // its own; parenthesized, it's an expression and the cell's result (#143).
166
+ edits.push({ start: decl.start, end: decl.declaration.start, text: anonymous ? "(" : "" });
167
+ if (anonymous)
168
+ edits.push({ start: decl.declaration.end, end: decl.declaration.end, text: ");" });
144
169
  break;
145
170
  }
146
171
  case "ExportAllDeclaration":
@@ -202,8 +227,8 @@ function patternNames(pattern, names) {
202
227
  * surrounding script, which a global-code eval turns into a property of
203
228
  * globalThis. Inside the function the declarations become assignments to
204
229
  * those globals:
205
- * const { a } = o; class K {} -> var a, K;
206
- * (async () => { ({ a } = o); K = class K {}; })()
230
+ * const { a } = o; class K {} -> var a, K; (async () => {
231
+ * ({ a } = o); K = class K {}; })()
207
232
  * So later cells, and closures from this one, all see one binding. A `var`
208
233
  * in a top-level block or loop head is global too, as in a script; block
209
234
  * scoped and function-local declarations stay where they are. Functions
@@ -277,18 +302,21 @@ function hoistForEval(js) {
277
302
  edits.push({ start: stmt.end, end: stmt.end, text: ";" });
278
303
  }
279
304
  else if (stmt.type === "ExpressionStatement" && i === program.body.length - 1) {
280
- replace(stmt, `return [${src(stmt.expression)}];`);
305
+ // Parenthesized, so `1, 2` returns [2] rather than [1, 2] (#140).
306
+ replace(stmt, `return [(${src(stmt.expression)})];`);
281
307
  }
282
308
  else {
283
309
  nestedVars(stmt);
284
310
  }
285
311
  });
286
- const declare = names.size > 0 ? `var ${[...names].join(", ")};\n` : "";
312
+ // All on the first line, so the cell always starts on line 2, as after
313
+ // transformCell's one-line prelude (#150).
314
+ const declare = names.size > 0 ? `var ${[...names].join(", ")}; ` : "";
287
315
  const publish = functions.map((name) => ` globalThis.${name} = ${name};`).join("");
288
316
  return `${declare}(async () => { 'use strict';${publish}\n${applyEdits(js, edits)}\n})()`;
289
317
  }
290
318
  export function transformCell(code, options = {}) {
291
- return PRELUDE + rewriteModuleSyntax(stripTypes(code, options));
319
+ return PRELUDE + rewriteModuleSyntax(stripTypes(code, options)) + SETTLE;
292
320
  }
293
321
  /**
294
322
  * transformCell, plus the map between the cell's lines and the script's,
@@ -297,7 +325,7 @@ export function transformCell(code, options = {}) {
297
325
  */
298
326
  export function transformCellWithLines(code, options = {}) {
299
327
  const stripped = stripTypesMapped(code, options, true);
300
- return { script: PRELUDE + rewriteModuleSyntax(stripped.code), lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
328
+ return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
301
329
  }
302
330
  /**
303
331
  * Like transformCell, for a target that evaluates cells itself, as global
package/dist/wire.d.ts CHANGED
@@ -11,12 +11,17 @@ export interface Header extends JsonObject {
11
11
  msg_type: string;
12
12
  version: string;
13
13
  }
14
+ /** A header as received: the fields the kernel relies on, plus whatever else came. */
15
+ export interface ReceivedHeader extends JsonObject {
16
+ msg_id: string;
17
+ msg_type: string;
18
+ }
14
19
  export interface Message {
15
20
  identities: Uint8Array[];
16
21
  sig: string;
17
22
  /** Raw bytes of header, parent_header, metadata, content as received. */
18
23
  signedParts: Uint8Array[];
19
- header: Header;
24
+ header: ReceivedHeader;
20
25
  parentHeader: JsonObject;
21
26
  metadata: JsonObject;
22
27
  content: JsonObject;
@@ -26,6 +31,8 @@ export declare function sign(key: string, parts: Uint8Array[]): string;
26
31
  export declare function verify(key: string, parts: Uint8Array[], sig: string): boolean;
27
32
  export declare function makeHeader(session: string, msgType: string): Header;
28
33
  export interface EncodeOptions {
34
+ /** Defaults to a fresh header; pass one to know its msg_id. */
35
+ header?: Header;
29
36
  identities?: Uint8Array[];
30
37
  parentHeader?: JsonObject;
31
38
  metadata?: JsonObject;