@tlockney/cdp-kernel 0.5.0 → 0.7.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 (74) hide show
  1. package/README.md +27 -6
  2. package/dist/browser/channels.d.ts +27 -0
  3. package/dist/browser/channels.js +90 -0
  4. package/dist/browser/kernel.js +29674 -0
  5. package/dist/browser/kernel.js.map +7 -0
  6. package/dist/browser/mod.d.ts +20 -0
  7. package/dist/browser/mod.js +38 -0
  8. package/dist/browser/target.d.ts +71 -0
  9. package/dist/browser/target.js +206 -0
  10. package/dist/browser/worker.js +1000 -0
  11. package/dist/browser/worker.js.map +7 -0
  12. package/dist/cdp.d.ts +11 -0
  13. package/dist/cdp.js +6 -1
  14. package/dist/celld-target.js +9 -12
  15. package/dist/children.d.ts +25 -3
  16. package/dist/children.js +84 -2
  17. package/dist/debugger.d.ts +2 -0
  18. package/dist/debugger.js +12 -6
  19. package/dist/deno-types.d.ts +30 -0
  20. package/dist/deno-types.js +93 -0
  21. package/dist/kernel-core.d.ts +69 -0
  22. package/dist/kernel-core.js +754 -0
  23. package/dist/kernel.d.ts +12 -1
  24. package/dist/kernel.js +132 -605
  25. package/dist/language-service-worker.d.ts +1 -0
  26. package/dist/language-service-worker.js +88 -0
  27. package/dist/language-service.d.ts +64 -0
  28. package/dist/language-service.js +128 -0
  29. package/dist/main.js +102 -8
  30. package/dist/message.d.ts +34 -0
  31. package/dist/message.js +14 -0
  32. package/dist/mod.d.ts +1 -0
  33. package/dist/mod.js +1 -0
  34. package/dist/notebook-types.d.ts +62 -0
  35. package/dist/notebook-types.js +310 -0
  36. package/dist/package.d.ts +2 -2
  37. package/dist/package.js +2 -2
  38. package/dist/parent.d.ts +12 -0
  39. package/dist/parent.js +39 -0
  40. package/dist/port-holder.d.ts +13 -0
  41. package/dist/port-holder.js +105 -0
  42. package/dist/sourcemap.d.ts +7 -2
  43. package/dist/sourcemap.js +9 -3
  44. package/dist/strip-esbuild.d.ts +2 -0
  45. package/dist/strip-esbuild.js +44 -0
  46. package/dist/strip-sucrase.d.ts +2 -0
  47. package/dist/strip-sucrase.js +78 -0
  48. package/dist/target-api.d.ts +80 -0
  49. package/dist/target-api.js +22 -0
  50. package/dist/target.d.ts +26 -74
  51. package/dist/target.js +232 -58
  52. package/dist/traceback.d.ts +75 -0
  53. package/dist/traceback.js +200 -0
  54. package/dist/transform.d.ts +22 -1
  55. package/dist/transform.js +46 -43
  56. package/dist/wire.d.ts +4 -24
  57. package/dist/wire.js +3 -12
  58. package/dist/workerd-target.js +1 -1
  59. package/dist/workers-project.js +13 -2
  60. package/dist/zmq-channels.d.ts +14 -0
  61. package/dist/zmq-channels.js +59 -0
  62. package/dist/zmtp.d.ts +2 -0
  63. package/dist/zmtp.js +12 -0
  64. package/package.json +15 -5
  65. package/runtime/bootstrap.mjs +113 -33
  66. package/runtime/browser-worker.mjs +119 -0
  67. package/runtime/cdn.d.mts +2 -0
  68. package/runtime/cdn.mjs +17 -0
  69. package/runtime/display.mjs +202 -1
  70. package/runtime/format.d.mts +2 -0
  71. package/runtime/format.mjs +125 -0
  72. package/runtime/introspect.mjs +1 -1
  73. package/runtime/node-hooks.d.mts +6 -0
  74. package/runtime/node-hooks.mjs +11 -0
@@ -0,0 +1,200 @@
1
+ // Structured tracebacks (#322): an error's stack as frames a frontend can
2
+ // render, link to cells and fold, alongside the classic `traceback` lines.
3
+ // The payload follows nteract's application/vnd.nteract.traceback+json
4
+ // (runtimed/runt, docs/adr/traceback-output-protocol.md): ename, evalue,
5
+ // frames with a source window and a `library` flag, and a plain `text`
6
+ // rendering. Building it must never cost the user their traceback: callers
7
+ // fall back to the classic lines if it throws.
8
+ //
9
+ // Cells run under a //# sourceURL naming them (cellName), so a frame in a
10
+ // cell, including one in a function an earlier cell defined, says which
11
+ // cell. Its line goes through the cell's source map, as for the debugger
12
+ // (src/sourcemap.ts), so it is the line the user wrote, not the line of the
13
+ // stripped JavaScript. Only lines are mapped: a cell frame has no column.
14
+ //
15
+ // Nothing here imports Node; reading other files' source is the caller's.
16
+ import { murmur2 } from "./murmur2.js";
17
+ export const TRACEBACK_MIME = "application/vnd.nteract.traceback+json";
18
+ /** Lines of source shown around a frame's line. */
19
+ const CONTEXT_BEFORE = 2;
20
+ const CONTEXT_AFTER = 2;
21
+ /** Longer stacks keep their first and last frames. */
22
+ const HEAD_FRAMES = 20;
23
+ const TAIL_FRAMES = 10;
24
+ /** The //# sourceURL a cell's script runs under while not debugging. */
25
+ export function cellName(code) {
26
+ return `$cdpk$cell-${murmur2(code, 0).toString(36)}.ts`;
27
+ }
28
+ // V8: " at fn (file:1:2)", " at file:1:2", " at async fn (file:1:2)",
29
+ // " at new Promise (<anonymous>)".
30
+ const V8_FRAME = /^\s*at (?:(.*?) \((.*?)(?::(\d+):(\d+))?\)|(.*?)(?::(\d+):(\d+))?)$/;
31
+ // JavaScriptCore and SpiderMonkey: "fn@file:1:2", "@file:1:2", "fn@[native code]".
32
+ const AT_FRAME = /^([^\s@]*)@(.+?)(?::(\d+):(\d+))?$/;
33
+ /** One line of a stack as a frame, or null for a line that isn't one (the message). */
34
+ export function parseFrame(text) {
35
+ const v8 = /^\s*at /.test(text) ? V8_FRAME.exec(text) : null;
36
+ if (v8) {
37
+ const named = v8[2] !== undefined;
38
+ const line = named ? v8[3] : v8[6];
39
+ const column = named ? v8[4] : v8[7];
40
+ return {
41
+ name: named ? v8[1] : "",
42
+ file: named ? v8[2] : v8[5],
43
+ line: line === undefined ? null : Number(line),
44
+ column: column === undefined ? null : Number(column),
45
+ };
46
+ }
47
+ const at = !/^\s/.test(text) && text.includes("@") ? AT_FRAME.exec(text) : null;
48
+ if (at) {
49
+ return {
50
+ name: at[1],
51
+ file: at[2],
52
+ line: at[3] === undefined ? null : Number(at[3]),
53
+ column: at[4] === undefined ? null : Number(at[4]),
54
+ };
55
+ }
56
+ return null;
57
+ }
58
+ /**
59
+ * Frames from packages, runtimes and the kernel itself, which a frontend
60
+ * can dim or fold: a hint, not a boundary.
61
+ */
62
+ export function isLibraryFile(file, kernelFiles = []) {
63
+ if (["", "<anonymous>", "native", "[native code]", "unknown"].includes(file))
64
+ return true;
65
+ if (/^(?:node|ext|deno|bun|jsr|npm|internal):/.test(file))
66
+ return true;
67
+ if (/^https?:\/\/(?:jsr\.io|esm\.sh|cdn\.jsdelivr\.net|unpkg\.com|deno\.land)\//.test(file))
68
+ return true;
69
+ const path = file.replaceAll("\\", "/");
70
+ if (path.includes("/node_modules/") || path.includes("/npm/registry.npmjs.org/"))
71
+ return true;
72
+ return kernelFiles.some((prefix) => path.startsWith(prefix));
73
+ }
74
+ function window(source, line) {
75
+ const all = source.split("\n");
76
+ if (all.length > 1 && all[all.length - 1] === "")
77
+ all.pop();
78
+ const out = [];
79
+ for (let n = Math.max(1, line - CONTEXT_BEFORE); n <= Math.min(all.length, line + CONTEXT_AFTER); n++) {
80
+ const entry = { lineno: n, source: all[n - 1].replace(/\r$/, "") };
81
+ if (n === line)
82
+ entry.highlight = true;
83
+ out.push(entry);
84
+ }
85
+ return out;
86
+ }
87
+ /** How a cell frame's location reads in the classic traceback. */
88
+ function cellLabel(cell) {
89
+ return cell.executionCount === null ? "cell" : `In[${cell.executionCount}]`;
90
+ }
91
+ /**
92
+ * `stack` is the error's stack as the target describes it (src/target.ts's
93
+ * describeError), message lines included. `callFrames`, from CDP, name the
94
+ * scripts of frames the stack shows as "<anonymous>": Deno formats stacks
95
+ * itself and doesn't use a script's //# sourceURL.
96
+ */
97
+ export function buildTraceback(error, sources, execution) {
98
+ const raw = error.stack || `${error.ename}: ${error.evalue}`;
99
+ const lines = [];
100
+ const frames = [];
101
+ const callFrames = error.callFrames ?? [];
102
+ let next = 0;
103
+ for (const text of raw.split("\n")) {
104
+ const parsed = parseFrame(text);
105
+ if (parsed === null) {
106
+ lines.push(text);
107
+ continue;
108
+ }
109
+ let file = parsed.file;
110
+ if (parsed.line !== null) {
111
+ // CDP lists only frames with a position, in the same order.
112
+ const at = callFrames.findIndex((frame, i) => i >= next && frame.lineNumber + 1 === parsed.line && frame.columnNumber + 1 === parsed.column);
113
+ if (at !== -1) {
114
+ next = at + 1;
115
+ if (file === "<anonymous>" && callFrames[at].url)
116
+ file = callFrames[at].url;
117
+ }
118
+ }
119
+ const cell = sources.cell(file);
120
+ if (cell !== undefined && parsed.line !== null) {
121
+ // Script lines are 1-based; the map's are 0-based.
122
+ const lineno = cell.lines ? cell.lines.toOriginal(parsed.line - 1) + 1 : parsed.line;
123
+ const frame = {
124
+ filename: cellLabel(cell),
125
+ lineno,
126
+ name: parsed.name,
127
+ library: false,
128
+ lines: window(cell.code, lineno),
129
+ };
130
+ if (cell.executionCount !== null)
131
+ frame.execution_count = cell.executionCount;
132
+ if (cell.cellId !== null)
133
+ frame.cell_id = cell.cellId;
134
+ frame.source_ref = {
135
+ kind: "notebook_execution",
136
+ ...(cell.executionCount !== null ? { execution_count: cell.executionCount } : {}),
137
+ ...(cell.cellId !== null ? { cell_id: cell.cellId } : {}),
138
+ };
139
+ frames.push(frame);
140
+ const location = `${cellLabel(cell)}:${lineno}`;
141
+ lines.push(rewriteLocation(text, parsed, location));
142
+ continue;
143
+ }
144
+ lines.push(text);
145
+ const library = isLibraryFile(file, sources.kernelFiles);
146
+ const frame = {
147
+ filename: file,
148
+ lineno: parsed.line ?? 0,
149
+ name: parsed.name,
150
+ library,
151
+ lines: [],
152
+ };
153
+ if (parsed.column !== null)
154
+ frame.colno = parsed.column;
155
+ if (!library && parsed.line !== null) {
156
+ const source = sources.readFile?.(file);
157
+ if (source !== undefined)
158
+ frame.lines = window(source, parsed.line);
159
+ }
160
+ frames.push(frame);
161
+ }
162
+ // What ran the cell (the kernel's evaluator, the runtime's own frames)
163
+ // comes after the cell's last frame and is never the user's.
164
+ const lastCell = frames.findLastIndex((frame) => frame.source_ref !== undefined);
165
+ let end = frames.length;
166
+ while (end > lastCell + 1 && lastCell !== -1 && frames[end - 1].library)
167
+ end--;
168
+ const kept = clip(frames.slice(0, end));
169
+ const payload = {
170
+ ename: error.ename,
171
+ evalue: error.evalue,
172
+ language: "typescript",
173
+ frames: kept,
174
+ syntax: null,
175
+ text: lines.join("\n"),
176
+ raw_text: raw,
177
+ };
178
+ if (execution.executionCount !== null || execution.cellId !== null) {
179
+ payload.execution = {
180
+ ...(execution.executionCount !== null ? { execution_count: execution.executionCount } : {}),
181
+ ...(execution.cellId !== null ? { cell_id: execution.cellId } : {}),
182
+ };
183
+ }
184
+ return { lines, payload };
185
+ }
186
+ function rewriteLocation(text, frame, location) {
187
+ const original = `${frame.file}:${frame.line}${frame.column === null ? "" : `:${frame.column}`}`;
188
+ const at = text.lastIndexOf(original);
189
+ return at === -1 ? text : text.slice(0, at) + location + text.slice(at + original.length);
190
+ }
191
+ function clip(frames) {
192
+ if (frames.length <= HEAD_FRAMES + TAIL_FRAMES)
193
+ return frames;
194
+ const omitted = frames.length - HEAD_FRAMES - TAIL_FRAMES;
195
+ return [
196
+ ...frames.slice(0, HEAD_FRAMES),
197
+ { filename: "", lineno: 0, name: `… ${omitted} frames omitted …`, library: true, lines: [] },
198
+ ...frames.slice(-TAIL_FRAMES),
199
+ ];
200
+ }
@@ -1,5 +1,6 @@
1
1
  import { LineMap } from "./sourcemap.ts";
2
2
  export declare const SHIM = "__cdpk";
3
+ export declare const YIELD = "await __cdpk.tick();\n";
3
4
  /**
4
5
  * Each replMode cell also ends by yielding one macrotask, so the script
5
6
  * settles with no other microtasks queued ahead of the inspector's
@@ -29,14 +30,34 @@ export type JsxOptions = {
29
30
  export interface CellTransformOptions {
30
31
  jsx?: JsxOptions;
31
32
  experimentalDecorators?: boolean;
33
+ /** What removes a cell's types and compiles its JSX; esbuild by default (#238, #234). */
34
+ typeStrip?: TypeStripperName | TypeStripper;
32
35
  }
36
+ /**
37
+ * Turns a TypeScript/TSX cell into JavaScript with ES module syntax left in
38
+ * place, and with `mappings` (a source map's field, lines are enough) when
39
+ * `sourcemap` is set, for the debugger. Throws CellSyntaxError for code it
40
+ * rejects.
41
+ */
42
+ export type TypeStripper = (code: string, options: CellTransformOptions, sourcemap: boolean) => {
43
+ code: string;
44
+ mappings: string;
45
+ };
46
+ export type TypeStripperName = "esbuild" | "sucrase";
47
+ export declare function isTypeStripperName(value: unknown): value is TypeStripperName;
48
+ /**
49
+ * Loads a built-in type stripper. Transforms are synchronous, so a kernel
50
+ * loads the one it uses before its first cell; only that one's module is
51
+ * imported (#238).
52
+ */
53
+ export declare function loadTypeStripper(name: TypeStripperName): Promise<TypeStripper>;
33
54
  /** Rewrites module-only syntax in plain JavaScript into script syntax. */
34
55
  export declare function rewriteModuleSyntax(js: string): string;
35
56
  export declare function transformCell(code: string, options?: CellTransformOptions): string;
36
57
  /**
37
58
  * transformCell, plus the map between the cell's lines and the script's,
38
59
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
39
- * script's lines are esbuild's, after the prelude's one.
60
+ * script's lines are the stripper's, after the prelude's one.
40
61
  */
41
62
  export declare function transformCellWithLines(code: string, options?: CellTransformOptions): {
42
63
  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";
@@ -24,7 +23,14 @@ export const SHIM = "__cdpk";
24
23
  // which is what lets a Runtime.terminateExecution from the control session
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
- const PRELUDE = `'use strict'; await ${SHIM}.tick();\n`;
26
+ //
27
+ // An expression that throws before its first await rejects the replMode
28
+ // result promise at once, inside that dispatch, and a GC before the
29
+ // inspector's then-callback runs fails the evaluate with "Promise was
30
+ // collected" (#329). Yielding first moves the throw into a task, where
31
+ // SETTLE's reasoning applies. The debug console uses YIELD for this.
32
+ export const YIELD = `await ${SHIM}.tick();\n`;
33
+ const PRELUDE = `'use strict'; ${YIELD}`;
28
34
  /**
29
35
  * Each replMode cell also ends by yielding one macrotask, so the script
30
36
  * settles with no other microtasks queued ahead of the inspector's
@@ -37,51 +43,48 @@ export const SETTLE = `\n;let ${SHIM}_settled = await ${SHIM}.tick();`;
37
43
  export class CellSyntaxError extends Error {
38
44
  name = "SyntaxError";
39
45
  }
40
- function jsxSettings(jsx) {
41
- if (jsx?.mode === "automatic") {
42
- return { jsx: "automatic", jsxImportSource: jsx.importSource, jsxDev: jsx.development };
46
+ export function isTypeStripperName(value) {
47
+ return value === "esbuild" || value === "sucrase";
48
+ }
49
+ const strippers = new Map();
50
+ /**
51
+ * Loads a built-in type stripper. Transforms are synchronous, so a kernel
52
+ * loads the one it uses before its first cell; only that one's module is
53
+ * imported (#238).
54
+ */
55
+ export async function loadTypeStripper(name) {
56
+ let stripper = strippers.get(name);
57
+ if (stripper === undefined) {
58
+ try {
59
+ stripper = name === "sucrase"
60
+ ? (await import("./strip-sucrase.js")).stripTypes
61
+ : (await import("./strip-esbuild.js")).stripTypes;
62
+ }
63
+ catch (err) {
64
+ const reason = err instanceof Error ? err.message : String(err);
65
+ throw new Error(`cannot load the ${name} type stripper (is the ${name} package installed?): ${reason}`);
66
+ }
67
+ strippers.set(name, stripper);
43
68
  }
44
- return { jsx: "transform", jsxFactory: jsx?.factory, jsxFragment: jsx?.fragment };
69
+ return stripper;
70
+ }
71
+ function typeStripper(choice = "esbuild") {
72
+ if (typeof choice === "function")
73
+ return choice;
74
+ const stripper = strippers.get(choice);
75
+ if (stripper === undefined)
76
+ throw new Error(`the ${choice} type stripper isn't loaded (see loadTypeStripper)`);
77
+ return stripper;
45
78
  }
46
79
  // esbuild compiles `import x = require("m")` to a `require` call, which a
47
80
  // cell doesn't have (#143); `import type x = require("m")` is erased.
48
81
  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. */
82
+ /** The stripper's output, with its source map's `mappings` when asked for. */
50
83
  function stripTypesMapped(code, options, sourcemap) {
51
84
  if (IMPORT_REQUIRE.test(code)) {
52
85
  throw new CellSyntaxError('import x = require("m") is not supported in a cell; use import x from "m"');
53
86
  }
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/, ""));
87
+ return typeStripper(options.typeStrip)(code, options, sourcemap);
85
88
  }
86
89
  function stripTypes(code, options) {
87
90
  return stripTypesMapped(code, options, false).code;
@@ -321,11 +324,11 @@ export function transformCell(code, options = {}) {
321
324
  /**
322
325
  * transformCell, plus the map between the cell's lines and the script's,
323
326
  * for the debugger (#18). The rewrite keeps lines (see applyEdits), so the
324
- * script's lines are esbuild's, after the prelude's one.
327
+ * script's lines are the stripper's, after the prelude's one.
325
328
  */
326
329
  export function transformCellWithLines(code, options = {}) {
327
330
  const stripped = stripTypesMapped(code, options, true);
328
- return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE)) };
331
+ return { script: PRELUDE + rewriteModuleSyntax(stripped.code) + SETTLE, lines: new LineMap(stripped.mappings, newlines(PRELUDE), stripped.code) };
329
332
  }
330
333
  /**
331
334
  * Like transformCell, for a target that evaluates cells itself, as global
package/dist/wire.d.ts CHANGED
@@ -1,35 +1,15 @@
1
+ import { type Header, type JsonObject, type KernelMessage } from "./message.ts";
2
+ export { makeHeader, PROTOCOL_VERSION } from "./message.ts";
3
+ export type { Header, JsonObject, ReceivedHeader } from "./message.ts";
1
4
  export declare const DELIMITER: Uint8Array<ArrayBuffer>;
2
- export declare const PROTOCOL_VERSION = "5.3";
3
- export type JsonObject = {
4
- [key: string]: unknown;
5
- };
6
- export interface Header extends JsonObject {
7
- msg_id: string;
8
- session: string;
9
- date: string;
10
- username: string;
11
- msg_type: string;
12
- version: string;
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
- }
19
- export interface Message {
5
+ export interface Message extends KernelMessage {
20
6
  identities: Uint8Array[];
21
7
  sig: string;
22
8
  /** Raw bytes of header, parent_header, metadata, content as received. */
23
9
  signedParts: Uint8Array[];
24
- header: ReceivedHeader;
25
- parentHeader: JsonObject;
26
- metadata: JsonObject;
27
- content: JsonObject;
28
- buffers: Uint8Array[];
29
10
  }
30
11
  export declare function sign(key: string, parts: Uint8Array[]): string;
31
12
  export declare function verify(key: string, parts: Uint8Array[], sig: string): boolean;
32
- export declare function makeHeader(session: string, msgType: string): Header;
33
13
  export interface EncodeOptions {
34
14
  /** Defaults to a fresh header; pass one to know its msg_id. */
35
15
  header?: Header;
package/dist/wire.js CHANGED
@@ -4,11 +4,12 @@
4
4
  // Copyright 2018-2026 the Deno authors, MIT license. The structure is kept;
5
5
  // WebCrypto's async HMAC is replaced with node:crypto's synchronous one.
6
6
  import { Buffer } from "node:buffer";
7
- import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
7
+ import { createHmac, timingSafeEqual } from "node:crypto";
8
+ import { makeHeader } from "./message.js";
9
+ export { makeHeader, PROTOCOL_VERSION } from "./message.js";
8
10
  const ENC = new TextEncoder();
9
11
  const DEC = new TextDecoder();
10
12
  export const DELIMITER = ENC.encode("<IDS|MSG>");
11
- export const PROTOCOL_VERSION = "5.3";
12
13
  export function sign(key, parts) {
13
14
  if (key.length === 0)
14
15
  return "";
@@ -29,16 +30,6 @@ export function verify(key, parts, sig) {
29
30
  const actual = Buffer.from(sig, "hex");
30
31
  return expected.length === actual.length && timingSafeEqual(expected, actual);
31
32
  }
32
- export function makeHeader(session, msgType) {
33
- return {
34
- msg_id: randomUUID(),
35
- session,
36
- date: new Date().toISOString(),
37
- username: "kernel",
38
- msg_type: msgType,
39
- version: PROTOCOL_VERSION,
40
- };
41
- }
42
33
  export function encodeMessage(session, key, msgType, content, options = {}) {
43
34
  const parts = [
44
35
  options.header ?? makeHeader(session, msgType),
@@ -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(() => { });
@@ -0,0 +1,14 @@
1
+ import type { ConnectionInfo } from "./kernel.ts";
2
+ import type { ChannelName, KernelChannels, Received } from "./kernel-core.ts";
3
+ import type { OutgoingMessage } from "./message.ts";
4
+ export declare class ZmqChannels implements KernelChannels {
5
+ #private;
6
+ readonly ready: Promise<unknown>;
7
+ constructor(conn: ConnectionInfo, log: (text: string) => void);
8
+ recv(channel: ChannelName): Promise<Received | null>;
9
+ shellQueued(): number;
10
+ reply(channel: ChannelName, peer: string, msg: OutgoingMessage): void;
11
+ publish(msg: OutgoingMessage): void;
12
+ stdinPeers(): number;
13
+ sendStdin(msg: OutgoingMessage): void;
14
+ }
@@ -0,0 +1,59 @@
1
+ // KernelChannels over ZeroMQ: the connection file's five ports, with
2
+ // messages framed and signed as src/wire.ts does it.
3
+ import { decodeMessage, encodeMessage, verify } from "./wire.js";
4
+ import { listening, PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
5
+ export class ZmqChannels {
6
+ ready;
7
+ #key;
8
+ #log;
9
+ #routers;
10
+ #iopub;
11
+ constructor(conn, log) {
12
+ this.#key = conn.key;
13
+ this.#log = log;
14
+ const heartbeat = startHeartbeat(conn.hb_port, conn.ip);
15
+ this.#routers = {
16
+ shell: new RouterSocket(conn.shell_port, conn.ip),
17
+ control: new RouterSocket(conn.control_port, conn.ip),
18
+ stdin: new RouterSocket(conn.stdin_port, conn.ip),
19
+ };
20
+ this.#iopub = new PubSocket(conn.iopub_port, conn.ip);
21
+ // Settles once all five ports are bound (#257).
22
+ this.ready = Promise.all([heartbeat, ...Object.values(this.#routers).map((r) => r.server), this.#iopub.server].map(listening));
23
+ }
24
+ async recv(channel) {
25
+ const { peer, frames } = await this.#routers[channel].recv();
26
+ try {
27
+ const msg = decodeMessage(frames);
28
+ // Unsigned or forged messages are dropped, as in Deno.
29
+ return verify(this.#key, msg.signedParts, msg.sig) ? { peer, msg } : null;
30
+ }
31
+ catch (err) {
32
+ this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
33
+ return null;
34
+ }
35
+ }
36
+ shellQueued() {
37
+ return this.#routers.shell.incoming.size;
38
+ }
39
+ #encode(msg) {
40
+ return encodeMessage(msg.header.session, this.#key, msg.header.msg_type, msg.content, {
41
+ header: msg.header,
42
+ parentHeader: msg.parentHeader,
43
+ metadata: msg.metadata,
44
+ buffers: msg.buffers,
45
+ });
46
+ }
47
+ reply(channel, peer, msg) {
48
+ this.#routers[channel].send(peer, this.#encode(msg));
49
+ }
50
+ publish(msg) {
51
+ this.#iopub.send(this.#encode(msg));
52
+ }
53
+ stdinPeers() {
54
+ return this.#routers.stdin.peers.size;
55
+ }
56
+ sendStdin(msg) {
57
+ this.#routers.stdin.sendAll(this.#encode(msg));
58
+ }
59
+ }
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) => {