@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
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
  }
@@ -20,14 +29,34 @@ export type JsxOptions = {
20
29
  export interface CellTransformOptions {
21
30
  jsx?: JsxOptions;
22
31
  experimentalDecorators?: boolean;
32
+ /** What removes a cell's types and compiles its JSX; esbuild by default (#238, #234). */
33
+ typeStrip?: TypeStripperName | TypeStripper;
23
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>;
24
53
  /** Rewrites module-only syntax in plain JavaScript into script syntax. */
25
54
  export declare function rewriteModuleSyntax(js: string): string;
26
55
  export declare function transformCell(code: string, options?: CellTransformOptions): string;
27
56
  /**
28
57
  * transformCell, plus the map between the cell's lines and the script's,
29
58
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
30
- * script's lines are esbuild's, after the prelude's one.
59
+ * script's lines are the stripper's, after the prelude's one.
31
60
  */
32
61
  export declare function transformCellWithLines(code: string, options?: CellTransformOptions): {
33
62
  script: string;
package/dist/transform.js CHANGED
@@ -2,11 +2,11 @@
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
- // 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
@@ -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";
@@ -25,45 +24,60 @@ export const SHIM = "__cdpk";
25
24
  // interrupt a busy loop. (Within that dispatch the inspector processes no
26
25
  // other messages, so an interrupt would never be seen.)
27
26
  const PRELUDE = `'use strict'; await ${SHIM}.tick();\n`;
27
+ /**
28
+ * Each replMode cell also ends by yielding one macrotask, so the script
29
+ * settles with no other microtasks queued ahead of the inspector's
30
+ * then-callback. Until that callback runs, the inspector holds the result
31
+ * promise only weakly, and a GC in between fails the evaluate with "Promise
32
+ * was collected" (#55). A declaration has no completion value, so the
33
+ * cell's value is unchanged; replMode lets `let` be declared again.
34
+ */
35
+ export const SETTLE = `\n;let ${SHIM}_settled = await ${SHIM}.tick();`;
28
36
  export class CellSyntaxError extends Error {
29
37
  name = "SyntaxError";
30
38
  }
31
- function jsxSettings(jsx) {
32
- if (jsx?.mode === "automatic") {
33
- return { jsx: "automatic", jsxImportSource: jsx.importSource, jsxDev: jsx.development };
34
- }
35
- return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
39
+ export function isTypeStripperName(value) {
40
+ return value === "esbuild" || value === "sucrase";
36
41
  }
37
- /** esbuild's output, with its source map's `mappings` when asked for. */
38
- function stripTypesMapped(code, options, sourcemap) {
39
- const compilerOptions = {
40
- verbatimModuleSyntax: true,
41
- ...(options.experimentalDecorators ? { experimentalDecorators: true } : {}),
42
- };
43
- // Like Deno: prefer TSX, fall back to TS for `<T>x` assertions.
44
- let firstError;
45
- for (const loader of ["tsx", "ts"]) {
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) {
46
51
  try {
47
- const result = transformSync(code, {
48
- loader,
49
- target: "esnext",
50
- ...jsxSettings(options.jsx),
51
- tsconfigRaw: { compilerOptions },
52
- logLevel: "silent",
53
- ...(sourcemap ? { sourcemap: "external" } : {}),
54
- });
55
- const map = sourcemap ? JSON.parse(result.map) : null;
56
- const mappings = typeof map?.mappings === "string"
57
- ? map.mappings
58
- : "";
59
- return { code: result.code, mappings };
52
+ stripper = name === "sucrase"
53
+ ? (await import("./strip-sucrase.js")).stripTypes
54
+ : (await import("./strip-esbuild.js")).stripTypes;
60
55
  }
61
56
  catch (err) {
62
- firstError ??= 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}`);
63
59
  }
60
+ strippers.set(name, stripper);
61
+ }
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;
71
+ }
72
+ // esbuild compiles `import x = require("m")` to a `require` call, which a
73
+ // cell doesn't have (#143); `import type x = require("m")` is erased.
74
+ const IMPORT_REQUIRE = /^\s*(?:export\s+)?import\s+(?!type\s)[\w$]+\s*=\s*require\s*\(/m;
75
+ /** The stripper's output, with its source map's `mappings` when asked for. */
76
+ function stripTypesMapped(code, options, sourcemap) {
77
+ if (IMPORT_REQUIRE.test(code)) {
78
+ throw new CellSyntaxError('import x = require("m") is not supported in a cell; use import x from "m"');
64
79
  }
65
- const message = firstError instanceof Error ? firstError.message : String(firstError);
66
- throw new CellSyntaxError(message.replace(/^Transform failed with \d+ errors?:\n/, ""));
80
+ return typeStripper(options.typeStrip)(code, options, sourcemap);
67
81
  }
68
82
  function stripTypes(code, options) {
69
83
  return stripTypesMapped(code, options, false).code;
@@ -80,8 +94,9 @@ function rewriteImport(code, node, index) {
80
94
  ? `, { with: { ${node.attributes.map((a) => code.slice(a.start, a.end)).join(", ")} } }`
81
95
  : "";
82
96
  const load = `await ${SHIM}.import(${source}${attrs})`;
97
+ // `void`: as a cell's last statement, the import isn't its result (#139).
83
98
  if (node.specifiers.length === 0)
84
- return `${load};`;
99
+ return `void ${load};`;
85
100
  const ns = `${SHIM}_m${index}`;
86
101
  const bindings = node.specifiers.map((spec) => {
87
102
  const local = String(spec.local.name);
@@ -140,7 +155,13 @@ export function rewriteModuleSyntax(js) {
140
155
  }
141
156
  case "ExportDefaultDeclaration": {
142
157
  const decl = node;
143
- edits.push({ start: decl.start, end: decl.declaration.start, text: "" });
158
+ const anonymous = (decl.declaration.type === "FunctionDeclaration" || decl.declaration.type === "ClassDeclaration") &&
159
+ decl.declaration.id === null;
160
+ // An anonymous `function () {}` or `class {}` isn't a statement on
161
+ // its own; parenthesized, it's an expression and the cell's result (#143).
162
+ edits.push({ start: decl.start, end: decl.declaration.start, text: anonymous ? "(" : "" });
163
+ if (anonymous)
164
+ edits.push({ start: decl.declaration.end, end: decl.declaration.end, text: ");" });
144
165
  break;
145
166
  }
146
167
  case "ExportAllDeclaration":
@@ -202,8 +223,8 @@ function patternNames(pattern, names) {
202
223
  * surrounding script, which a global-code eval turns into a property of
203
224
  * globalThis. Inside the function the declarations become assignments to
204
225
  * those globals:
205
- * const { a } = o; class K {} -> var a, K;
206
- * (async () => { ({ a } = o); K = class K {}; })()
226
+ * const { a } = o; class K {} -> var a, K; (async () => {
227
+ * ({ a } = o); K = class K {}; })()
207
228
  * So later cells, and closures from this one, all see one binding. A `var`
208
229
  * in a top-level block or loop head is global too, as in a script; block
209
230
  * scoped and function-local declarations stay where they are. Functions
@@ -277,27 +298,30 @@ function hoistForEval(js) {
277
298
  edits.push({ start: stmt.end, end: stmt.end, text: ";" });
278
299
  }
279
300
  else if (stmt.type === "ExpressionStatement" && i === program.body.length - 1) {
280
- replace(stmt, `return [${src(stmt.expression)}];`);
301
+ // Parenthesized, so `1, 2` returns [2] rather than [1, 2] (#140).
302
+ replace(stmt, `return [(${src(stmt.expression)})];`);
281
303
  }
282
304
  else {
283
305
  nestedVars(stmt);
284
306
  }
285
307
  });
286
- const declare = names.size > 0 ? `var ${[...names].join(", ")};\n` : "";
308
+ // All on the first line, so the cell always starts on line 2, as after
309
+ // transformCell's one-line prelude (#150).
310
+ const declare = names.size > 0 ? `var ${[...names].join(", ")}; ` : "";
287
311
  const publish = functions.map((name) => ` globalThis.${name} = ${name};`).join("");
288
312
  return `${declare}(async () => { 'use strict';${publish}\n${applyEdits(js, edits)}\n})()`;
289
313
  }
290
314
  export function transformCell(code, options = {}) {
291
- return PRELUDE + rewriteModuleSyntax(stripTypes(code, options));
315
+ return PRELUDE + rewriteModuleSyntax(stripTypes(code, options)) + SETTLE;
292
316
  }
293
317
  /**
294
318
  * transformCell, plus the map between the cell's lines and the script's,
295
319
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
296
- * script's lines are esbuild's, after the prelude's one.
320
+ * script's lines are the stripper's, after the prelude's one.
297
321
  */
298
322
  export function transformCellWithLines(code, options = {}) {
299
323
  const stripped = stripTypesMapped(code, options, true);
300
- return { script: PRELUDE + rewriteModuleSyntax(stripped.code), lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
324
+ return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE), stripped.code) };
301
325
  }
302
326
  /**
303
327
  * 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;
package/dist/wire.js CHANGED
@@ -41,7 +41,7 @@ export function makeHeader(session, msgType) {
41
41
  }
42
42
  export function encodeMessage(session, key, msgType, content, options = {}) {
43
43
  const parts = [
44
- makeHeader(session, msgType),
44
+ options.header ?? makeHeader(session, msgType),
45
45
  options.parentHeader ?? {},
46
46
  options.metadata ?? {},
47
47
  content,
@@ -70,6 +70,8 @@ function parseObject(frame) {
70
70
  }
71
71
  return value;
72
72
  }
73
+ // Only msg_id and msg_type are required of a received header; nothing
74
+ // reads the rest, so a client that leaves them out still works.
73
75
  function isHeader(value) {
74
76
  return typeof value.msg_id === "string" && typeof value.msg_type === "string";
75
77
  }
@@ -1,4 +1,4 @@
1
- import type { CdpParams, EvaluateResult, RemoteObject } from "./cdp.ts";
1
+ import { type CdpParams, type EvaluateResult, type RemoteObject } from "./cdp.ts";
2
2
  import { transformCellForEval } from "./transform.ts";
3
3
  import { type KernelTarget, type TargetEvents } from "./target.ts";
4
4
  import { type WsChannel } from "./ws-client.ts";
@@ -53,6 +53,12 @@ export interface WorkerHost {
53
53
  stop(now: boolean): void;
54
54
  onExit(listener: (code: number | null) => void): void;
55
55
  }
56
+ /**
57
+ * Removes workerd directories under `root` whose kernel has died without
58
+ * cleaning up (SIGKILL runs no exit hook), killing their workerd if it's
59
+ * still running (#220).
60
+ */
61
+ export declare function sweepStaleWorkerdDirs(root?: string): void;
56
62
  /** The driver on a workerd of its own, behind a Unix socket. */
57
63
  export declare class RawWorkerd implements WorkerHost {
58
64
  #private;
@@ -18,18 +18,20 @@ import { Buffer } from "node:buffer";
18
18
  import process from "node:process";
19
19
  import { execFileSync, spawn } from "node:child_process";
20
20
  import { randomBytes } from "node:crypto";
21
- import { copyFileSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { builtinModules } from "node:module";
23
23
  import { tmpdir } from "node:os";
24
24
  import { dirname, join } from "node:path";
25
25
  import { pathToFileURL } from "node:url";
26
26
  import { build } from "esbuild";
27
+ import { asEvaluateResult } from "./cdp.js";
27
28
  import { transformCellForEval } from "./transform.js";
28
29
  import { runtimeDir } from "./package.js";
30
+ import { killOnExit } from "./children.js";
29
31
  import { Interrupted, isTargetMessage } from "./target.js";
30
32
  import { connectUnixWebSocket } from "./ws-client.js";
31
33
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
32
- import { ensureInstalled, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
34
+ import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
33
35
  /** The driver Worker's modules; the first is its main module. */
34
36
  export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
35
37
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
@@ -121,7 +123,8 @@ export async function bundleModule(specifier, cwd) {
121
123
  ? jsrToNpm(specifier.slice(4))
122
124
  : null;
123
125
  if (pkg) {
124
- const { dir, alias } = ensureInstalled(pkg, "npm", { lockFile: findLockFile(cwd) });
126
+ // Async: a sync install would stall the heartbeat and interrupts (#149).
127
+ const { dir, alias } = await ensureInstalledAsync(pkg, "npm", { lockFile: findLockFile(cwd) });
125
128
  entry = alias + pkg.subpath;
126
129
  resolveDir = dir;
127
130
  }
@@ -168,6 +171,65 @@ export function cellMeta(cwd) {
168
171
  const replUrl = new URL("$cdpk$repl.mjs", pathToFileURL(cwd.endsWith("/") ? cwd : cwd + "/"));
169
172
  return { url: replUrl.href, dirname: cwd, filename: join(cwd, "$cdpk$repl.mjs") };
170
173
  }
174
+ const WORKERD_DIR_PREFIX = "cdpk-workerd-";
175
+ /** In each workerd directory: `{ host, workerd }` pids. */
176
+ const OWNER_FILE = "owner.json";
177
+ function pidAlive(pid) {
178
+ try {
179
+ process.kill(pid, 0);
180
+ return true;
181
+ }
182
+ catch (err) {
183
+ return err.code === "EPERM";
184
+ }
185
+ }
186
+ /** Whether `pid` is still the workerd serving `dir`, not a reused pid. */
187
+ function isWorkerdIn(pid, dir) {
188
+ try {
189
+ if (process.platform === "linux")
190
+ return readlinkSync(`/proc/${pid}/cwd`) === dir;
191
+ const args = execFileSync("ps", ["-o", "args=", "-p", String(pid)], { encoding: "utf8" });
192
+ return /workerd/.test(args) && args.includes("config.capnp");
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ /**
199
+ * Removes workerd directories under `root` whose kernel has died without
200
+ * cleaning up (SIGKILL runs no exit hook), killing their workerd if it's
201
+ * still running (#220).
202
+ */
203
+ export function sweepStaleWorkerdDirs(root = tmpdir()) {
204
+ let names;
205
+ try {
206
+ names = readdirSync(root).filter((name) => name.startsWith(WORKERD_DIR_PREFIX));
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const name of names) {
212
+ const dir = join(root, name);
213
+ let owner;
214
+ try {
215
+ owner = JSON.parse(readFileSync(join(dir, OWNER_FILE), "utf8"));
216
+ }
217
+ catch {
218
+ continue; // Another user's, from before owner files, or being set up.
219
+ }
220
+ if (typeof owner.host !== "number" || pidAlive(owner.host))
221
+ continue;
222
+ if (typeof owner.workerd === "number" && isWorkerdIn(owner.workerd, dir)) {
223
+ try {
224
+ process.kill(owner.workerd, "SIGKILL");
225
+ }
226
+ catch {
227
+ // Gone already.
228
+ }
229
+ }
230
+ rmSync(dir, { recursive: true, force: true });
231
+ }
232
+ }
171
233
  /** The driver on a workerd of its own, behind a Unix socket. */
172
234
  export class RawWorkerd {
173
235
  runtime;
@@ -184,7 +246,9 @@ export class RawWorkerd {
184
246
  const source = await runtimeDir(new URL("../runtime/", import.meta.url).href);
185
247
  // mkdtemp makes the directory 0700: only this user reaches the socket,
186
248
  // and the token stops anything else running as them by accident.
187
- const dir = mkdtempSync(join(tmpdir(), "cdpk-workerd-"));
249
+ sweepStaleWorkerdDirs();
250
+ const dir = mkdtempSync(join(tmpdir(), WORKERD_DIR_PREFIX));
251
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ host: process.pid }));
188
252
  const socket = join(dir, "sock");
189
253
  for (const name of WORKER_MODULES)
190
254
  copyFileSync(join(source, name), join(dir, name));
@@ -194,11 +258,21 @@ export class RawWorkerd {
194
258
  // --experimental: the UnsafeEval binding.
195
259
  const child = spawn(workerd, ["serve", "config.capnp", "--experimental"], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env });
196
260
  child.once("exit", () => rmSync(dir, { recursive: true, force: true }));
261
+ if (child.pid !== undefined)
262
+ writeFileSync(join(dir, OWNER_FILE), JSON.stringify({ host: process.pid, workerd: child.pid }));
263
+ killOnExit(child, () => {
264
+ child.kill("SIGKILL");
265
+ rmSync(dir, { recursive: true, force: true });
266
+ }, { dir });
197
267
  // Cell output is captured inside the Worker; what workerd itself prints
198
268
  // (its own warnings, errors from background work) goes to the server log.
269
+ // `early` keeps that output for a startup error only, and stops growing
270
+ // once the session is open.
199
271
  let early = "";
272
+ let starting = true;
200
273
  const log = (chunk) => {
201
- early += chunk.toString();
274
+ if (starting)
275
+ early += chunk.toString();
202
276
  process.stderr.write(`[cdp-kernel workerd] ${chunk}`);
203
277
  };
204
278
  child.stdout?.on("data", log);
@@ -221,7 +295,10 @@ export class RawWorkerd {
221
295
  }
222
296
  })();
223
297
  try {
224
- return new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
298
+ const host = new RawWorkerd(child, await Promise.race([ready, exited]), compatibilityDate);
299
+ starting = false;
300
+ early = "";
301
+ return host;
225
302
  }
226
303
  catch (err) {
227
304
  child.kill();
@@ -229,7 +306,16 @@ export class RawWorkerd {
229
306
  }
230
307
  }
231
308
  stop(now) {
232
- this.#child.kill(now ? "SIGKILL" : "SIGTERM");
309
+ if (now) {
310
+ this.#child.kill("SIGKILL");
311
+ return;
312
+ }
313
+ this.#child.kill("SIGTERM");
314
+ // A busy isolate keeps workerd's event loop from ever seeing SIGTERM (#128).
315
+ setTimeout(() => {
316
+ if (this.#child.exitCode === null && this.#child.signalCode === null)
317
+ this.#child.kill("SIGKILL");
318
+ }, 3000).unref();
233
319
  }
234
320
  onExit(listener) {
235
321
  this.#child.once("exit", listener);
@@ -342,6 +428,9 @@ export class WorkerdTarget {
342
428
  this.#pending.clear();
343
429
  }
344
430
  #request(op, params = {}, interruptible = false) {
431
+ // The channel drops writes after it closes, so nothing would answer.
432
+ if (this.#exitedOnce)
433
+ return Promise.reject(new Abandoned("the workerd session is closed"));
345
434
  const id = ++this.#nextId;
346
435
  return new Promise((resolve, reject) => {
347
436
  this.#pending.set(id, { resolve, reject });
@@ -377,7 +466,12 @@ export class WorkerdTarget {
377
466
  if (!modules[specifier].startsWith("Promise.reject("))
378
467
  this.#sent.add(specifier);
379
468
  }
380
- return (await this.#interruptibly("eval", { code: script, modules }, "Execution"));
469
+ const { failedModules, ...reply } = await this.#interruptibly("eval", { code: script, modules }, "Execution");
470
+ // A module that threw while loading is rebuilt for its next import (#147).
471
+ if (Array.isArray(failedModules))
472
+ for (const specifier of failedModules)
473
+ this.#sent.delete(specifier);
474
+ return asEvaluateResult(reply);
381
475
  }
382
476
  /**
383
477
  * A cell that's waiting on something leaves the driver free: it answers a
@@ -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(() => { });
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  export declare const OP_CONTINUATION = 0;
3
3
  export declare const OP_TEXT = 1;
4
+ export declare const OP_BINARY = 2;
4
5
  export declare const OP_CLOSE = 8;
5
6
  export declare const OP_PING = 9;
6
7
  export declare const OP_PONG = 10;
@@ -11,7 +12,10 @@ export interface Frame {
11
12
  }
12
13
  /** One frame; clients must mask what they send. */
13
14
  export declare function encodeFrame(opcode: number, payload: Buffer, mask?: Buffer | null): Buffer;
14
- /** Splits a byte stream into frames, holding back a partial one. */
15
+ /**
16
+ * Splits a byte stream into frames, holding back a partial one. Chunks of a
17
+ * large frame are only joined once the whole frame has arrived.
18
+ */
15
19
  export declare class FrameDecoder {
16
20
  #private;
17
21
  push(chunk: Buffer): Frame[];