@tlockney/cdp-kernel 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +27 -6
  2. package/dist/cdp.d.ts +2 -0
  3. package/dist/cdp.js +6 -1
  4. package/dist/celld-target.js +9 -12
  5. package/dist/children.d.ts +25 -3
  6. package/dist/children.js +84 -2
  7. package/dist/deno-types.d.ts +30 -0
  8. package/dist/deno-types.js +93 -0
  9. package/dist/kernel.d.ts +7 -1
  10. package/dist/kernel.js +187 -29
  11. package/dist/language-service-worker.d.ts +1 -0
  12. package/dist/language-service-worker.js +88 -0
  13. package/dist/language-service.d.ts +64 -0
  14. package/dist/language-service.js +128 -0
  15. package/dist/main.js +102 -8
  16. package/dist/mod.d.ts +1 -0
  17. package/dist/mod.js +1 -0
  18. package/dist/notebook-types.d.ts +62 -0
  19. package/dist/notebook-types.js +310 -0
  20. package/dist/package.d.ts +2 -2
  21. package/dist/package.js +2 -2
  22. package/dist/parent.d.ts +12 -0
  23. package/dist/parent.js +39 -0
  24. package/dist/port-holder.d.ts +13 -0
  25. package/dist/port-holder.js +105 -0
  26. package/dist/sourcemap.d.ts +7 -2
  27. package/dist/sourcemap.js +9 -3
  28. package/dist/strip-esbuild.d.ts +2 -0
  29. package/dist/strip-esbuild.js +44 -0
  30. package/dist/strip-sucrase.d.ts +2 -0
  31. package/dist/strip-sucrase.js +78 -0
  32. package/dist/target.d.ts +27 -3
  33. package/dist/target.js +193 -36
  34. package/dist/transform.d.ts +21 -1
  35. package/dist/transform.js +38 -42
  36. package/dist/workerd-target.js +1 -1
  37. package/dist/workers-project.js +13 -2
  38. package/dist/zmtp.d.ts +2 -0
  39. package/dist/zmtp.js +12 -0
  40. package/package.json +9 -4
  41. package/runtime/bootstrap.mjs +113 -33
  42. package/runtime/introspect.mjs +1 -1
  43. package/runtime/node-hooks.d.mts +6 -0
  44. package/runtime/node-hooks.mjs +11 -0
package/dist/target.js CHANGED
@@ -7,11 +7,18 @@ import { spawn } from "node:child_process";
7
7
  import { createRequire } from "node:module";
8
8
  import { join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { asEvaluateResult, CdpAbandoned, CdpSession } from "./cdp.js";
10
+ import { asEvaluateResult, CdpAbandoned, CdpError, CdpSession } from "./cdp.js";
11
11
  import { SHIM, transformCell } from "./transform.js";
12
12
  import { hostRuntime, isInsideNodeModules, runtimeDir } from "./package.js";
13
13
  import { killOnExit } from "./children.js";
14
- const BINDING = "__cdpk_send";
14
+ /**
15
+ * The inspector keeps every object it returns a remote reference to alive
16
+ * until it is released, so a cell's result would never be collected (#286).
17
+ * Cells and lookups put theirs in these groups, which are released once the
18
+ * kernel is done with them.
19
+ */
20
+ const CELL_GROUP = "cdpk-cell";
21
+ const LOOKUP_GROUP = "cdpk-lookup";
15
22
  /** How long a target may take from spawn to a usable session. */
16
23
  const START_TIMEOUT_MS = 60_000;
17
24
  const MARKER_RE = /\x1b\[cdpk:(\d+)(m?)\]\x1b/;
@@ -27,7 +34,9 @@ const INSPECTOR_CHATTER = [
27
34
  "Visit chrome://inspect",
28
35
  "Waiting for the debugger to disconnect",
29
36
  ];
30
- const DENO_CHATTER = [...INSPECTOR_CHATTER, "Download http://", "Download https://"];
37
+ // Deno prints "Debugger session started." for each session the host opens
38
+ // (#281).
39
+ const DENO_CHATTER = [...INSPECTOR_CHATTER, "Debugger session started.", "Download http://", "Download https://"];
31
40
  const ANSI_SGR = /\x1b\[[0-9;]*m/g;
32
41
  /** A colour code cut off at the end of a chunk. */
33
42
  const PARTIAL_SGR = /\x1b(?:\[[0-9;]*)?$/;
@@ -92,6 +101,10 @@ export function isTargetMessage(value) {
92
101
  typeof value.barrier === "number";
93
102
  }
94
103
  const MARKER_PREFIX = "\x1b[cdpk:";
104
+ // A message record: the message's JSON, which never holds a raw ESC, between
105
+ // "\x1b[cdpk:<" and "\x1b]" (#271).
106
+ const RECORD_PREFIX = "\x1b[cdpk:<";
107
+ const RECORD_RE = /\x1b\[cdpk:<([^\x1b]*)\x1b\]/;
95
108
  function isPartialMarker(tail) {
96
109
  if (tail.length >= MAX_MARKER_LEN)
97
110
  return false;
@@ -99,10 +112,13 @@ function isPartialMarker(tail) {
99
112
  }
100
113
  /**
101
114
  * Splits one output pipe into text and barrier markers, and releases them
102
- * in order. A gated marker (one that announces a binding message) pauses
115
+ * in order. A gated marker (one that announces a message) pauses
103
116
  * the pipe until `gate(n)` resolves, i.e. until that message has been
104
117
  * delivered, so later output can't overtake it. A possible partial marker
105
118
  * at the end of a chunk is held back until the next chunk decides it.
119
+ *
120
+ * With `onMessage`, the pipe also carries message records. A record stands
121
+ * where its gated marker would, and pauses the pipe the same way.
106
122
  */
107
123
  export class PipeDemux {
108
124
  #buffer = "";
@@ -111,28 +127,49 @@ export class PipeDemux {
111
127
  #closed = false;
112
128
  #reached = 0;
113
129
  #waiters = [];
130
+ /** The gated marker the pipe is paused on, if any. */
131
+ #gatedOn = null;
114
132
  onText;
115
133
  gate;
116
- constructor(onText, gate = () => Promise.resolve()) {
134
+ onMessage;
135
+ constructor(onText, gate = () => Promise.resolve(), onMessage = null) {
117
136
  this.onText = onText;
118
137
  this.gate = gate;
138
+ this.onMessage = onMessage;
119
139
  }
120
140
  push(chunk) {
121
141
  this.#buffer += chunk;
122
142
  for (;;) {
123
143
  const m = MARKER_RE.exec(this.#buffer);
124
- if (!m)
144
+ const r = this.onMessage ? RECORD_RE.exec(this.#buffer) : null;
145
+ const first = m && r ? (m.index < r.index ? m : r) : m ?? r;
146
+ if (!first)
125
147
  break;
126
- if (m.index > 0)
127
- this.#items.push({ text: this.#buffer.slice(0, m.index) });
128
- this.#items.push({ barrier: Number(m[1]), gated: m[2] === "m" });
129
- this.#buffer = this.#buffer.slice(m.index + m[0].length);
148
+ if (first.index > 0)
149
+ this.#items.push({ text: this.#buffer.slice(0, first.index) });
150
+ if (first === m) {
151
+ this.#items.push({ barrier: Number(m[1]), gated: m[2] === "m" });
152
+ }
153
+ else {
154
+ // Anything else a cell prints in this shape is dropped (#122).
155
+ const msg = parseJson(first[1]);
156
+ if (isTargetMessage(msg))
157
+ this.#items.push({ message: msg });
158
+ }
159
+ this.#buffer = this.#buffer.slice(first.index + first[0].length);
130
160
  }
131
161
  // Hold back a tail that could still grow into a marker, including one
132
- // cut inside the "\x1b[cdpk:" prefix itself.
162
+ // cut inside the "\x1b[cdpk:" prefix itself, or into a record: one
163
+ // whose only ESC after its prefix is a final one.
133
164
  const esc = this.#buffer.lastIndexOf("\x1b");
134
165
  const tail = esc === -1 ? "" : this.#buffer.slice(esc);
135
- const holdFrom = esc !== -1 && isPartialMarker(tail) ? esc : this.#buffer.length;
166
+ let holdFrom = esc !== -1 && isPartialMarker(tail) ? esc : this.#buffer.length;
167
+ const record = this.onMessage ? this.#buffer.lastIndexOf(RECORD_PREFIX) : -1;
168
+ if (record !== -1) {
169
+ const next = this.#buffer.indexOf("\x1b", record + RECORD_PREFIX.length);
170
+ if (next === -1 || next === this.#buffer.length - 1)
171
+ holdFrom = Math.min(holdFrom, record);
172
+ }
136
173
  if (holdFrom > 0) {
137
174
  this.#items.push({ text: this.#buffer.slice(0, holdFrom) });
138
175
  this.#buffer = this.#buffer.slice(holdFrom);
@@ -149,9 +186,22 @@ export class PipeDemux {
149
186
  this.onText(item.text);
150
187
  continue;
151
188
  }
189
+ if ("message" in item) {
190
+ this.#reach(item.message.barrier);
191
+ this.onMessage?.(item.message);
192
+ if (!this.#closed) {
193
+ this.#gatedOn = item.message.barrier;
194
+ await this.gate(item.message.barrier);
195
+ this.#gatedOn = null;
196
+ }
197
+ continue;
198
+ }
152
199
  this.#reach(item.barrier);
153
- if (item.gated && !this.#closed)
200
+ if (item.gated && !this.#closed) {
201
+ this.#gatedOn = item.barrier;
154
202
  await this.gate(item.barrier);
203
+ this.#gatedOn = null;
204
+ }
155
205
  }
156
206
  }
157
207
  finally {
@@ -172,6 +222,16 @@ export class PipeDemux {
172
222
  return Promise.resolve();
173
223
  return new Promise((resolve) => this.#waiters.push({ n, resolve }));
174
224
  }
225
+ /** Where the pipe stands, for a state dump (#237). */
226
+ state() {
227
+ return {
228
+ reached: this.#reached,
229
+ waitingFor: this.#waiters.map((w) => w.n),
230
+ gatedOn: this.#gatedOn,
231
+ queued: this.#items.length,
232
+ held: this.#buffer.length,
233
+ };
234
+ }
175
235
  /** Release everything (the process died). */
176
236
  close() {
177
237
  this.#closed = true;
@@ -287,6 +347,8 @@ export class Target {
287
347
  #gates;
288
348
  #contextId;
289
349
  #messages = Promise.resolve();
350
+ /** Messages received but not yet delivered. */
351
+ #undelivered = 0;
290
352
  #interrupted = false;
291
353
  #paused = false;
292
354
  /** Set once the main context is gone: the runtime is exiting (#130). */
@@ -332,6 +394,9 @@ export class Target {
332
394
  delete env.NO_COLOR;
333
395
  const child = spawn(cmd, args, { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"], env });
334
396
  killOnExit(child);
397
+ // Prompt replies and comm messages go to stdin; one written as the
398
+ // runtime exits fails with EPIPE, and the exit is handled on its own.
399
+ child.stdin.on("error", () => { });
335
400
  // A start that fails or stalls must not leave the runtime running: its
336
401
  // pipes and inspector sockets would keep this process alive (#52).
337
402
  const sessions = [];
@@ -357,7 +422,12 @@ export class Target {
357
422
  child.stdout.setEncoding("utf8");
358
423
  child.stderr.setEncoding("utf8");
359
424
  const gates = new Gates();
360
- const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n));
425
+ // Messages come only after attach(), so the target exists by then.
426
+ let target;
427
+ const stdout = new PipeDemux((text) => events.stream("stdout", text), (n) => gates.wait(n), (msg) => {
428
+ if (target)
429
+ target.#deliver(msg, events);
430
+ });
361
431
  const stderr = new PipeDemux((text) => events.stream("stderr", text), (n) => gates.wait(n));
362
432
  const filter = new StderrFilter(runtime, (text) => stderr.push(text));
363
433
  const pushStderr = (chunk) => filter.push(chunk);
@@ -398,25 +468,22 @@ export class Target {
398
468
  });
399
469
  });
400
470
  await shell.send("Runtime.enable");
401
- await shell.send("Runtime.addBinding", { name: BINDING });
402
471
  const contextId = await contextReady;
403
- const target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
472
+ // The inspector socket leaves Nagle's algorithm on, so an event written
473
+ // just before an evaluate's reply holds that reply until the event is
474
+ // acknowledged, ~40 ms on Linux (#271). So the shell session, which runs
475
+ // the cells, takes no Runtime events (console calls among them), and
476
+ // messages come over stdout. The control session watches for exit.
477
+ await shell.send("Runtime.disable");
478
+ await control.send("Runtime.enable");
479
+ target = new Target(runtime, child, shell, control, stdout, stderr, gates, contextId, { runtime: "", engine: "" });
404
480
  // process.exit() or Deno.exit() destroys the context, then the runtime
405
481
  // waits for its inspector clients to disconnect before it exits. Let go,
406
482
  // so it exits and the kernel starts a fresh one (#130).
407
- shell.on("Runtime.executionContextDestroyed", (params) => {
408
- if (params.executionContextId === contextId)
483
+ control.on("Runtime.executionContextDestroyed", (params) => {
484
+ if (params.executionContextId === contextId && target)
409
485
  target.#onExit();
410
486
  });
411
- shell.on("Runtime.bindingCalled", (params) => {
412
- if (params.name !== BINDING || typeof params.payload !== "string")
413
- return;
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);
417
- if (isTargetMessage(msg))
418
- target.#deliver(msg, events);
419
- });
420
487
  // Wait for the bootstrap's top-level await to finish installing the
421
488
  // shim. There is no cap here: launch's start timeout bounds the wait and
422
489
  // kills the child, so a slow bootstrap never yields a target without
@@ -427,6 +494,7 @@ export class Target {
427
494
  break;
428
495
  await new Promise((r) => setTimeout(r, 25));
429
496
  }
497
+ await target.#evaluateSync(`${SHIM}.attach()`);
430
498
  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 })`);
431
499
  Object.assign(target.versions, JSON.parse(String(versions.result.value)));
432
500
  // The runtime's own enumerable globals, before any cell: the debugger's
@@ -449,15 +517,65 @@ export class Target {
449
517
  baselineGlobals: this.#baselineGlobals,
450
518
  };
451
519
  }
452
- // Binding messages wait until both pipes have caught up to their marker,
520
+ // Messages wait until both pipes have caught up to their marker,
453
521
  // and are delivered strictly in order.
454
522
  #deliver(msg, events) {
523
+ this.#undelivered++;
455
524
  this.#messages = this.#messages.then(async () => {
456
525
  await Promise.all([this.#stdout.waitFor(msg.barrier), this.#stderr.waitFor(msg.barrier)]);
526
+ this.#undelivered--;
457
527
  events.message(msg);
458
528
  this.#gates.open(msg.barrier);
459
529
  });
460
530
  }
531
+ /**
532
+ * What the host is waiting on, whether the runtime still evaluates, runs
533
+ * immediates and runs timers, and where its JavaScript is if it is
534
+ * running any, for a kernel that has stopped answering (#237). The probes
535
+ * go over the control session, which no cell holds.
536
+ */
537
+ async diagnose() {
538
+ const within = (promise, fallback) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(fallback), 2000).unref())]);
539
+ const probe = (expression, awaitPromise) => within(this.#control.send("Runtime.evaluate", { expression, contextId: this.#contextId, awaitPromise, returnByValue: true })
540
+ .then((res) => res.exceptionDetails ? `threw: ${JSON.stringify(res.exceptionDetails)}` : String(res.result?.value), (err) => `failed: ${err}`), "no answer in 2 s");
541
+ const [evaluates, immediates, timers] = await Promise.all([
542
+ probe("'yes'", false),
543
+ probe("new Promise((resolve) => setImmediate(() => resolve('yes')))", true),
544
+ probe("new Promise((resolve) => setTimeout(() => resolve('yes'), 0))", true),
545
+ ]);
546
+ // A runtime whose event loop is stuck may be stuck in JavaScript: say
547
+ // where. (An idle one would take the pause in whatever runs next.)
548
+ let running = "event loop runs";
549
+ if (immediates !== "yes") {
550
+ try {
551
+ const paused = new Promise((resolve) => this.#control.on("Debugger.paused", resolve));
552
+ await this.#control.send("Debugger.enable");
553
+ await this.#control.send("Debugger.pause");
554
+ const event = await within(paused, null);
555
+ running = event === null ? "no" : event.callFrames
556
+ .slice(0, 10)
557
+ .map((f) => `${f.functionName || "(anonymous)"} ${f.url || "(no url)"}:${f.location.lineNumber + 1}`);
558
+ await this.#control.send("Debugger.resume").catch(() => { });
559
+ await this.#control.send("Debugger.disable");
560
+ }
561
+ catch (err) {
562
+ running = `failed: ${err}`;
563
+ }
564
+ }
565
+ return {
566
+ pid: this.#child.pid ?? null,
567
+ calls: { shell: this.#shell.pendingCalls(), control: this.#control.pendingCalls() },
568
+ stdout: this.#stdout.state(),
569
+ stderr: this.#stderr.state(),
570
+ undelivered: this.#undelivered,
571
+ paused: this.#paused,
572
+ exiting: this.#exiting,
573
+ evaluates,
574
+ immediates,
575
+ timers,
576
+ running,
577
+ };
578
+ }
461
579
  /** Resolves once all output and messages sent so far have been delivered. */
462
580
  async flush() {
463
581
  const n = await this.#evaluateSync(`${SHIM}.barrier()`).catch(interrupted("Execution"));
@@ -479,7 +597,13 @@ export class Target {
479
597
  }
480
598
  #evaluateRaw(expression, session = this.#shell) {
481
599
  return session
482
- .send("Runtime.evaluate", { expression, contextId: this.#contextId, replMode: true, awaitPromise: true })
600
+ .send("Runtime.evaluate", {
601
+ expression,
602
+ contextId: this.#contextId,
603
+ replMode: true,
604
+ awaitPromise: true,
605
+ objectGroup: CELL_GROUP,
606
+ })
483
607
  .then(asEvaluateResult);
484
608
  }
485
609
  /** Runs transformed cell code. Rejects with Interrupted after interrupt(). */
@@ -491,6 +615,10 @@ export class Target {
491
615
  catch (err) {
492
616
  if (err instanceof CdpAbandoned || this.#interrupted)
493
617
  throw new Interrupted("Execution interrupted");
618
+ // The control session's executionContextDestroyed event can arrive
619
+ // after the shell session's error reply (#271 moved it there).
620
+ if (err instanceof CdpError && err.message.includes("Execution context was destroyed"))
621
+ this.#onExit();
494
622
  if (this.#exiting) {
495
623
  // The child's exit, which drops this target in the kernel, comes
496
624
  // first, so the next cell starts a fresh one.
@@ -500,6 +628,11 @@ export class Target {
500
628
  throw err;
501
629
  }
502
630
  }
631
+ async releaseResults() {
632
+ if (this.#exiting)
633
+ return;
634
+ await this.#shell.send("Runtime.releaseObjectGroup", { objectGroup: CELL_GROUP }).catch(() => { });
635
+ }
503
636
  #onExit() {
504
637
  if (this.#exiting)
505
638
  return;
@@ -573,14 +706,19 @@ export class Target {
573
706
  */
574
707
  async dispatchComm(msgType, content, metadata, buffers) {
575
708
  this.#interrupted = false;
709
+ // Over stdin, not as call arguments: V8 caches an argument's source,
710
+ // so the data would stay in the target's memory (#287).
711
+ const record = Buffer.from(JSON.stringify({
712
+ msgType,
713
+ content,
714
+ metadata,
715
+ buffers: buffers.map((b) => Buffer.from(b).toString("base64")),
716
+ }));
717
+ this.#child.stdin.write(`comm ${record.length}\n`);
718
+ this.#child.stdin.write(record);
576
719
  let res;
577
720
  try {
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
- ]);
721
+ res = await this.#callAsyncFunctionOn(`function () { return globalThis.${SHIM}.commDispatchFromStdin(); }`, []);
584
722
  }
585
723
  catch (err) {
586
724
  if (err instanceof CdpAbandoned || this.#interrupted)
@@ -611,11 +749,22 @@ export class Target {
611
749
  */
612
750
  #evaluatePure(expression) {
613
751
  return this.#shell
614
- .send("Runtime.evaluate", { expression, contextId: this.#contextId, throwOnSideEffect: true })
752
+ .send("Runtime.evaluate", { expression, contextId: this.#contextId, throwOnSideEffect: true, objectGroup: LOOKUP_GROUP })
615
753
  .then(asEvaluateResult)
616
754
  .catch(() => null);
617
755
  }
756
+ #releaseLookup() {
757
+ return this.#shell.send("Runtime.releaseObjectGroup", { objectGroup: LOOKUP_GROUP }).then(() => { }, () => { });
758
+ }
618
759
  async propertyNames(expression) {
760
+ try {
761
+ return await this.#propertyNames(expression);
762
+ }
763
+ finally {
764
+ await this.#releaseLookup();
765
+ }
766
+ }
767
+ async #propertyNames(expression) {
619
768
  const evaluated = await this.#evaluatePure(`(${expression})`);
620
769
  const objectId = evaluated?.result.objectId;
621
770
  if (!objectId || evaluated?.exceptionDetails)
@@ -625,6 +774,14 @@ export class Target {
625
774
  return (list ?? []).map((p) => p.name);
626
775
  }
627
776
  async inspect(path, detail) {
777
+ try {
778
+ return await this.#inspect(path, detail);
779
+ }
780
+ finally {
781
+ await this.#releaseLookup();
782
+ }
783
+ }
784
+ async #inspect(path, detail) {
628
785
  const found = await this.#evaluatePure(path);
629
786
  if (!found || found.exceptionDetails)
630
787
  return null;
@@ -29,14 +29,34 @@ export type JsxOptions = {
29
29
  export interface CellTransformOptions {
30
30
  jsx?: JsxOptions;
31
31
  experimentalDecorators?: boolean;
32
+ /** What removes a cell's types and compiles its JSX; esbuild by default (#238, #234). */
33
+ typeStrip?: TypeStripperName | TypeStripper;
32
34
  }
35
+ /**
36
+ * Turns a TypeScript/TSX cell into JavaScript with ES module syntax left in
37
+ * place, and with `mappings` (a source map's field, lines are enough) when
38
+ * `sourcemap` is set, for the debugger. Throws CellSyntaxError for code it
39
+ * rejects.
40
+ */
41
+ export type TypeStripper = (code: string, options: CellTransformOptions, sourcemap: boolean) => {
42
+ code: string;
43
+ mappings: string;
44
+ };
45
+ export type TypeStripperName = "esbuild" | "sucrase";
46
+ export declare function isTypeStripperName(value: unknown): value is TypeStripperName;
47
+ /**
48
+ * Loads a built-in type stripper. Transforms are synchronous, so a kernel
49
+ * loads the one it uses before its first cell; only that one's module is
50
+ * imported (#238).
51
+ */
52
+ export declare function loadTypeStripper(name: TypeStripperName): Promise<TypeStripper>;
33
53
  /** Rewrites module-only syntax in plain JavaScript into script syntax. */
34
54
  export declare function rewriteModuleSyntax(js: string): string;
35
55
  export declare function transformCell(code: string, options?: CellTransformOptions): string;
36
56
  /**
37
57
  * transformCell, plus the map between the cell's lines and the script's,
38
58
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
39
- * script's lines are esbuild's, after the prelude's one.
59
+ * script's lines are the stripper's, after the prelude's one.
40
60
  */
41
61
  export declare function transformCellWithLines(code: string, options?: CellTransformOptions): {
42
62
  script: string;
package/dist/transform.js CHANGED
@@ -2,9 +2,9 @@
2
2
  // CDP's Runtime.evaluate can run with replMode: true.
3
3
  //
4
4
  // Deno does this with deno_ast (swc) and `var_decl_imports: true`
5
- // (cli/tools/repl/session.rs, evaluate_ts_expression). Here esbuild does the
6
- // TypeScript/JSX part and acorn finds the module syntax that a script can't
7
- // contain:
5
+ // (cli/tools/repl/session.rs, evaluate_ts_expression). Here a type stripper
6
+ // (esbuild by default) does the TypeScript/JSX part and acorn finds the
7
+ // module syntax that a script can't contain:
8
8
  // import x, { y as z } from "m" -> var __cdpk_m0 = await __cdpk.import("m");
9
9
  // var x = __cdpk_m0["default"], z = __cdpk_m0["y"];
10
10
  // import("m") -> __cdpk.import("m")
@@ -14,7 +14,6 @@
14
14
  // import() through it matters: code evaluated over the inspector has no
15
15
  // module referrer, so Node rejects a bare import() ("A dynamic import
16
16
  // callback was not specified").
17
- import { transformSync } from "esbuild";
18
17
  import { parse } from "acorn";
19
18
  import { full, recursive } from "acorn-walk";
20
19
  import { LineMap } from "./sourcemap.js";
@@ -37,51 +36,48 @@ export const SETTLE = `\n;let ${SHIM}_settled = await ${SHIM}.tick();`;
37
36
  export class CellSyntaxError extends Error {
38
37
  name = "SyntaxError";
39
38
  }
40
- function jsxSettings(jsx) {
41
- if (jsx?.mode === "automatic") {
42
- return { jsx: "automatic", jsxImportSource: jsx.importSource, jsxDev: jsx.development };
39
+ export function isTypeStripperName(value) {
40
+ return value === "esbuild" || value === "sucrase";
41
+ }
42
+ const strippers = new Map();
43
+ /**
44
+ * Loads a built-in type stripper. Transforms are synchronous, so a kernel
45
+ * loads the one it uses before its first cell; only that one's module is
46
+ * imported (#238).
47
+ */
48
+ export async function loadTypeStripper(name) {
49
+ let stripper = strippers.get(name);
50
+ if (stripper === undefined) {
51
+ try {
52
+ stripper = name === "sucrase"
53
+ ? (await import("./strip-sucrase.js")).stripTypes
54
+ : (await import("./strip-esbuild.js")).stripTypes;
55
+ }
56
+ catch (err) {
57
+ const reason = err instanceof Error ? err.message : String(err);
58
+ throw new Error(`cannot load the ${name} type stripper (is the ${name} package installed?): ${reason}`);
59
+ }
60
+ strippers.set(name, stripper);
43
61
  }
44
- return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
62
+ return stripper;
63
+ }
64
+ function typeStripper(choice = "esbuild") {
65
+ if (typeof choice === "function")
66
+ return choice;
67
+ const stripper = strippers.get(choice);
68
+ if (stripper === undefined)
69
+ throw new Error(`the ${choice} type stripper isn't loaded (see loadTypeStripper)`);
70
+ return stripper;
45
71
  }
46
72
  // esbuild compiles `import x = require("m")` to a `require` call, which a
47
73
  // cell doesn't have (#143); `import type x = require("m")` is erased.
48
74
  const IMPORT_REQUIRE = /^\s*(?:export\s+)?import\s+(?!type\s)[\w$]+\s*=\s*require\s*\(/m;
49
- /** esbuild's output, with its source map's `mappings` when asked for. */
75
+ /** The stripper's output, with its source map's `mappings` when asked for. */
50
76
  function stripTypesMapped(code, options, sourcemap) {
51
77
  if (IMPORT_REQUIRE.test(code)) {
52
78
  throw new CellSyntaxError('import x = require("m") is not supported in a cell; use import x from "m"');
53
79
  }
54
- const compilerOptions = {
55
- verbatimModuleSyntax: true,
56
- ...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
57
- };
58
- // Like Deno: prefer TSX, fall back to TS for `<T>x` assertions.
59
- let firstError;
60
- for (const loader of ["tsx", "ts"]) {
61
- try {
62
- const result = transformSync(code, {
63
- loader,
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 },
68
- ...jsxSettings(options.jsx),
69
- tsconfigRaw: { compilerOptions },
70
- logLevel: "silent",
71
- ...(sourcemap ? { sourcemap: "external" } : {}),
72
- });
73
- const map = sourcemap ? JSON.parse(result.map) : null;
74
- const mappings = typeof map?.mappings === "string"
75
- ? map.mappings
76
- : "";
77
- return { code: result.code, mappings };
78
- }
79
- catch (err) {
80
- firstError ??= err;
81
- }
82
- }
83
- const message = firstError instanceof Error ? firstError.message : String(firstError);
84
- throw new CellSyntaxError(message.replace(/^Transform failed with \d+ errors?:\n/, ""));
80
+ return typeStripper(options.typeStrip)(code, options, sourcemap);
85
81
  }
86
82
  function stripTypes(code, options) {
87
83
  return stripTypesMapped(code, options, false).code;
@@ -321,11 +317,11 @@ export function transformCell(code, options = {}) {
321
317
  /**
322
318
  * transformCell, plus the map between the cell's lines and the script's,
323
319
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
324
- * script's lines are esbuild's, after the prelude's one.
320
+ * script's lines are the stripper's, after the prelude's one.
325
321
  */
326
322
  export function transformCellWithLines(code, options = {}) {
327
323
  const stripped = stripTypesMapped(code, options, true);
328
- return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
324
+ return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE), stripped.code) };
329
325
  }
330
326
  /**
331
327
  * Like transformCell, for a target that evaluates cells itself, as global
@@ -263,7 +263,7 @@ export class RawWorkerd {
263
263
  killOnExit(child, () => {
264
264
  child.kill("SIGKILL");
265
265
  rmSync(dir, { recursive: true, force: true });
266
- });
266
+ }, { dir });
267
267
  // Cell output is captured inside the Worker; what workerd itself prints
268
268
  // (its own warnings, errors from background work) goes to the server log.
269
269
  // `early` keeps that output for a startup error only, and stops growing
@@ -9,11 +9,13 @@
9
9
  // bundled next to the driver, so its Durable Object classes are exported
10
10
  // from the same Worker. Neither wrangler nor Miniflare is a dependency of
11
11
  // cdp-kernel: they're loaded from the project, so they match its version.
12
+ import process from "node:process";
12
13
  import { builtinModules, createRequire } from "node:module";
13
14
  import { randomBytes } from "node:crypto";
14
15
  import { existsSync, readFileSync } from "node:fs";
15
16
  import { dirname, join } from "node:path";
16
17
  import { build } from "esbuild";
18
+ import { childPids, reap } from "./children.js";
17
19
  import { runtimeDir } from "./package.js";
18
20
  import { cellMeta, WORKER_MODULES } from "./workerd-target.js";
19
21
  export const WRANGLER_CONFIGS = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
@@ -128,8 +130,12 @@ class MiniflareHost {
128
130
  #mf;
129
131
  #exitListeners = [];
130
132
  #stopped = false;
131
- constructor(mf, channel, version, project) {
133
+ #forget;
134
+ constructor(mf, channel, version, project, workerd) {
132
135
  this.#mf = mf;
136
+ // Miniflare kills its workerd only on a normal exit; the helper in
137
+ // src/children.ts kills it if this process is SIGKILLed (#241).
138
+ this.#forget = workerd.map(reap);
133
139
  this.channel = channel;
134
140
  this.runtime = `miniflare ${version}`.trim();
135
141
  this.project = project;
@@ -140,6 +146,8 @@ class MiniflareHost {
140
146
  this.#stopped = true;
141
147
  // dispose() ends workerd even mid-cell.
142
148
  void this.#mf.dispose().catch(() => { }).finally(() => {
149
+ for (const forget of this.#forget)
150
+ forget();
143
151
  for (const listener of this.#exitListeners)
144
152
  listener(null);
145
153
  });
@@ -178,6 +186,8 @@ export async function startMiniflare(configPath, cwd) {
178
186
  // Miniflare 5 renamed Miniflare 4's defaultPersistRoot.
179
187
  ...(v5 ? { resourcePersistencePath: state } : { defaultPersistRoot: state }),
180
188
  };
189
+ // Miniflare's workerd is this process's child; it doesn't say which.
190
+ const before = new Set(childPids(process.pid, "workerd"));
181
191
  const mf = new miniflare.Miniflare(v5 && miniflare.convertV4MiniflareOptions ? miniflare.convertV4MiniflareOptions(options) : options);
182
192
  try {
183
193
  await mf.ready;
@@ -186,7 +196,8 @@ export async function startMiniflare(configPath, cwd) {
186
196
  const res = await mf.dispatchFetch("http://cdpk/", { headers: { Upgrade: "websocket", "x-cdpk-token": token } });
187
197
  if (res.status !== 101 || res.webSocket === null)
188
198
  throw new Error(`the driver answered ${res.status}: ${await res.text()}`);
189
- return new MiniflareHost(mf, channelOf(res.webSocket), version, configPath);
199
+ const workerd = childPids(process.pid, "workerd").filter((pid) => !before.has(pid));
200
+ return new MiniflareHost(mf, channelOf(res.webSocket), version, configPath, workerd);
190
201
  }
191
202
  catch (err) {
192
203
  await mf.dispose().catch(() => { });
package/dist/zmtp.d.ts CHANGED
@@ -25,6 +25,8 @@ export declare class Queue<T> {
25
25
  push(item: T): void;
26
26
  pop(): Promise<T>;
27
27
  }
28
+ /** Resolves once `server` is listening, after any retries. */
29
+ export declare function listening(server: Server): Promise<void>;
28
30
  /** REP socket for the heartbeat channel: echo everything. */
29
31
  export declare function startHeartbeat(port: number, host: string): Server;
30
32
  export interface RoutedMessage {
package/dist/zmtp.js CHANGED
@@ -8,6 +8,8 @@
8
8
  import { Buffer } from "node:buffer";
9
9
  import { createServer } from "node:net";
10
10
  import { randomBytes } from "node:crypto";
11
+ import process from "node:process";
12
+ import { portHolders } from "./port-holder.js";
11
13
  const ENC = new TextEncoder();
12
14
  /** Pull-style reader over a socket's 'data' events. */
13
15
  export class SocketReader {
@@ -154,6 +156,12 @@ function listen(port, host, onConn) {
154
156
  const deadline = Date.now() + LISTEN_RETRY_MS;
155
157
  const onError = (err) => {
156
158
  if (err.code !== "EADDRINUSE" || Date.now() >= deadline) {
159
+ // Who kept it, for a port that never came free (#257).
160
+ if (err.code === "EADDRINUSE") {
161
+ const holders = portHolders(port).map((line) => ` ${line}\n`).join("") || " unknown\n";
162
+ process.stderr.write(`[cdp-kernel] port ${port} still in use after ${LISTEN_RETRY_MS} ms; this kernel is pid ${process.pid} ` +
163
+ `(ppid ${process.ppid}, ${process.execPath} ${process.version}); the port's sockets:\n${holders}`);
164
+ }
157
165
  server.off("error", onError);
158
166
  server.emit("error", err);
159
167
  return;
@@ -187,6 +195,10 @@ export class Queue {
187
195
  return new Promise((resolve) => this.#waiters.push(resolve));
188
196
  }
189
197
  }
198
+ /** Resolves once `server` is listening, after any retries. */
199
+ export function listening(server) {
200
+ return server.listening ? Promise.resolve() : new Promise((resolve) => server.once("listening", () => resolve()));
201
+ }
190
202
  /** REP socket for the heartbeat channel: echo everything. */
191
203
  export function startHeartbeat(port, host) {
192
204
  return listen(port, host, (socket) => {