@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/ws-client.js CHANGED
@@ -2,13 +2,14 @@
2
2
  // target's session with its driver Worker (#26). The WebSocket that Node
3
3
  // and Deno provide can't connect to a Unix socket, and a TCP listener would
4
4
  // be reachable by every local process. Text messages only: that's all the
5
- // session carries.
5
+ // session carries, so binary messages are dropped.
6
6
  import { Buffer } from "node:buffer";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
8
  import { connect } from "node:net";
9
9
  const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
10
10
  export const OP_CONTINUATION = 0x0;
11
11
  export const OP_TEXT = 0x1;
12
+ export const OP_BINARY = 0x2;
12
13
  export const OP_CLOSE = 0x8;
13
14
  export const OP_PING = 0x9;
14
15
  export const OP_PONG = 0xa;
@@ -40,14 +41,24 @@ export function encodeFrame(opcode, payload, mask = randomBytes(4)) {
40
41
  }
41
42
  return out;
42
43
  }
43
- /** Splits a byte stream into frames, holding back a partial one. */
44
+ /**
45
+ * Splits a byte stream into frames, holding back a partial one. Chunks of a
46
+ * large frame are only joined once the whole frame has arrived.
47
+ */
44
48
  export class FrameDecoder {
45
- #buffer = Buffer.alloc(0);
49
+ #chunks = [];
50
+ #length = 0;
51
+ /** Bytes the frame at the front needs, once its header is known. */
52
+ #needed = 0;
46
53
  push(chunk) {
47
- this.#buffer = Buffer.concat([this.#buffer, chunk]);
54
+ this.#chunks.push(chunk);
55
+ this.#length += chunk.length;
56
+ if (this.#length < this.#needed)
57
+ return [];
58
+ let b = this.#chunks.length === 1 ? this.#chunks[0] : Buffer.concat(this.#chunks, this.#length);
59
+ this.#needed = 0;
48
60
  const frames = [];
49
61
  for (;;) {
50
- const b = this.#buffer;
51
62
  if (b.length < 2)
52
63
  break;
53
64
  const masked = (b[1] & 0x80) !== 0;
@@ -68,15 +79,19 @@ export class FrameDecoder {
68
79
  const maskAt = offset;
69
80
  if (masked)
70
81
  offset += 4;
71
- if (b.length < offset + len)
82
+ if (b.length < offset + len) {
83
+ this.#needed = offset + len;
72
84
  break;
85
+ }
73
86
  const payload = Buffer.from(b.subarray(offset, offset + len));
74
87
  if (masked)
75
88
  for (let i = 0; i < len; i++)
76
89
  payload[i] ^= b[maskAt + (i & 3)];
77
90
  frames.push({ fin: (b[0] & 0x80) !== 0, opcode: b[0] & 0x0f, payload });
78
- this.#buffer = b.subarray(offset + len);
91
+ b = b.subarray(offset + len);
79
92
  }
93
+ this.#chunks = b.length ? [b] : [];
94
+ this.#length = b.length;
80
95
  return frames;
81
96
  }
82
97
  }
@@ -95,6 +110,9 @@ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } =
95
110
  let head = Buffer.alloc(0);
96
111
  const decoder = new FrameDecoder();
97
112
  let parts = [];
113
+ // The opcode of the message whose fragments are arriving.
114
+ let messageOpcode = null;
115
+ let closeSent = false;
98
116
  const finish = () => {
99
117
  if (closed)
100
118
  return;
@@ -108,30 +126,45 @@ export function connectUnixWebSocket(socketPath, { path = "/", headers = {} } =
108
126
  if (!closed)
109
127
  socket.write(encodeFrame(opcode, payload));
110
128
  };
129
+ const sendClose = (payload) => {
130
+ if (closeSent)
131
+ return;
132
+ closeSent = true;
133
+ write(OP_CLOSE, payload);
134
+ };
111
135
  const channel = {
112
136
  send: (text) => write(OP_TEXT, Buffer.from(text)),
113
137
  onMessage: (listener) => void messageListeners.push(listener),
114
138
  onClose: (listener) => void closeListeners.push(listener),
115
139
  close() {
116
- write(OP_CLOSE, Buffer.alloc(0));
140
+ sendClose(Buffer.alloc(0));
117
141
  socket.end();
118
142
  },
119
143
  };
120
144
  const onFrames = (frames) => {
121
145
  for (const frame of frames) {
122
- if (frame.opcode === OP_TEXT || frame.opcode === OP_CONTINUATION) {
123
- parts.push(frame.payload);
146
+ if (frame.opcode === OP_TEXT || frame.opcode === OP_BINARY || frame.opcode === OP_CONTINUATION) {
147
+ if (frame.opcode !== OP_CONTINUATION) {
148
+ messageOpcode = frame.opcode;
149
+ parts = [];
150
+ }
151
+ if (messageOpcode === OP_TEXT)
152
+ parts.push(frame.payload);
124
153
  if (frame.fin) {
125
- const text = Buffer.concat(parts).toString("utf8");
154
+ const text = messageOpcode === OP_TEXT ? Buffer.concat(parts).toString("utf8") : null;
126
155
  parts = [];
127
- for (const listener of messageListeners)
128
- listener(text);
156
+ messageOpcode = null;
157
+ if (text !== null)
158
+ for (const listener of messageListeners)
159
+ listener(text);
129
160
  }
130
161
  }
131
162
  else if (frame.opcode === OP_PING) {
132
163
  write(OP_PONG, frame.payload);
133
164
  }
134
165
  else if (frame.opcode === OP_CLOSE) {
166
+ // Echo the status code, as RFC 6455 section 5.5.1 asks.
167
+ sendClose(frame.payload.subarray(0, 2));
135
168
  socket.end();
136
169
  }
137
170
  }
package/dist/zmtp.d.ts CHANGED
@@ -20,16 +20,24 @@ export declare function recvMultipart(reader: SocketReader): Promise<Uint8Array[
20
20
  /** Async FIFO with blocking pop. */
21
21
  export declare class Queue<T> {
22
22
  #private;
23
+ /** Items waiting to be popped. */
24
+ get size(): number;
23
25
  push(item: T): void;
24
26
  pop(): Promise<T>;
25
27
  }
28
+ /** Resolves once `server` is listening, after any retries. */
29
+ export declare function listening(server: Server): Promise<void>;
26
30
  /** REP socket for the heartbeat channel: echo everything. */
27
31
  export declare function startHeartbeat(port: number, host: string): Server;
28
32
  export interface RoutedMessage {
29
33
  peer: string;
30
34
  frames: Uint8Array[];
31
35
  }
32
- /** ROUTER socket: each TCP connection is one peer with a generated identity. */
36
+ /**
37
+ * ROUTER socket: each TCP connection is one peer with a generated identity.
38
+ * Writes ignore socket.write's backpressure signal: node:net buffers the
39
+ * rest, which is fine for loopback peers that read promptly.
40
+ */
33
41
  export declare class RouterSocket {
34
42
  #private;
35
43
  readonly incoming: Queue<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 {
@@ -36,6 +38,10 @@ export class SocketReader {
36
38
  this.#waiter = null;
37
39
  waiter?.();
38
40
  }
41
+ // Each call joins every buffered chunk, so a reader that keeps a large
42
+ // backlog while small chunks trickle in recopies it per call (quadratic).
43
+ // Jupyter traffic over loopback arrives in few, large chunks; a chunk
44
+ // list with an offset would avoid the copies if that ever changes.
39
45
  async readExact(n) {
40
46
  while (this.#buffered < n) {
41
47
  if (this.#error)
@@ -136,11 +142,34 @@ export async function recvMultipart(reader) {
136
142
  return frames;
137
143
  }
138
144
  }
145
+ /** How long a port from the connection file may stay taken before the kernel gives up. */
146
+ const LISTEN_RETRY_MS = 5000;
147
+ const LISTEN_RETRY_INTERVAL_MS = 100;
139
148
  function listen(port, host, onConn) {
140
149
  const server = createServer((socket) => {
141
150
  socket.setNoDelay(true);
142
151
  onConn(socket);
143
152
  });
153
+ // The frontend picks these ports by binding and releasing them, so another
154
+ // socket can take one before the kernel binds it. Wait for it to be freed
155
+ // rather than die before kernel_info.
156
+ const deadline = Date.now() + LISTEN_RETRY_MS;
157
+ const onError = (err) => {
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
+ }
165
+ server.off("error", onError);
166
+ server.emit("error", err);
167
+ return;
168
+ }
169
+ setTimeout(() => server.listen(port, host), LISTEN_RETRY_INTERVAL_MS);
170
+ };
171
+ server.on("error", onError);
172
+ server.once("listening", () => server.off("error", onError));
144
173
  server.listen(port, host);
145
174
  return server;
146
175
  }
@@ -148,6 +177,10 @@ function listen(port, host, onConn) {
148
177
  export class Queue {
149
178
  #items = [];
150
179
  #waiters = [];
180
+ /** Items waiting to be popped. */
181
+ get size() {
182
+ return this.#items.length;
183
+ }
151
184
  push(item) {
152
185
  const waiter = this.#waiters.shift();
153
186
  if (waiter)
@@ -162,6 +195,10 @@ export class Queue {
162
195
  return new Promise((resolve) => this.#waiters.push(resolve));
163
196
  }
164
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
+ }
165
202
  /** REP socket for the heartbeat channel: echo everything. */
166
203
  export function startHeartbeat(port, host) {
167
204
  return listen(port, host, (socket) => {
@@ -173,7 +210,11 @@ export function startHeartbeat(port, host) {
173
210
  })().catch(() => socket.destroy());
174
211
  });
175
212
  }
176
- /** ROUTER socket: each TCP connection is one peer with a generated identity. */
213
+ /**
214
+ * ROUTER socket: each TCP connection is one peer with a generated identity.
215
+ * Writes ignore socket.write's backpressure signal: node:net buffers the
216
+ * rest, which is fine for loopback peers that read promptly.
217
+ */
177
218
  export class RouterSocket {
178
219
  incoming = new Queue();
179
220
  peers = new Map();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.4.0",
4
- "description": "Deno's Jupyter kernel, extracted to run notebooks on any V8 runtime over the Chrome DevTools Protocol",
3
+ "version": "0.6.0",
4
+ "description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
7
7
  "repository": {
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "type": "module",
22
22
  "engines": {
23
- "node": ">=24"
23
+ "node": ">=22.18"
24
24
  },
25
25
  "bin": {
26
26
  "cdp-kernel": "dist/main.js"
@@ -42,7 +42,8 @@
42
42
  "files": [
43
43
  "dist/",
44
44
  "runtime/*.mjs",
45
- "runtime/*.d.mts"
45
+ "runtime/*.d.mts",
46
+ "THIRD-PARTY-NOTICES"
46
47
  ],
47
48
  "publishConfig": {
48
49
  "access": "public"
@@ -50,6 +51,10 @@
50
51
  "scripts": {
51
52
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
53
  "check": "tsc -p tsconfig.json",
54
+ "docs:dev": "vitepress dev docs",
55
+ "docs:build": "vitepress build docs",
56
+ "docs:notebooks": "node docs/.vitepress/run-notebooks.ts",
57
+ "docs:widgets": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/widget_screenshots.py",
53
58
  "install:node": "node src/main.ts install --runtime node",
54
59
  "install:deno": "node src/main.ts install --runtime deno",
55
60
  "install:node-deno": "node src/main.ts install --runtime node --deno-compat",
@@ -59,17 +64,23 @@
59
64
  "prepack": "npm run build",
60
65
  "test": "node --test --test-timeout=300000 test/*.test.ts",
61
66
  "e2e": "uv run --with nbclient --with nbformat --with jupyter_client python test/e2e.py",
67
+ "bench": "uv run --with jupyter_client python test/bench.py",
68
+ "soak": "uv run --with jupyter_client python test/soak.py",
62
69
  "test:lab": "uv run --with jupyterlab --with anywidget --with playwright --with nbformat python test/lab_widgets.py"
63
70
  },
64
71
  "dependencies": {
65
72
  "acorn": "^8.15.0",
66
73
  "acorn-walk": "^8.3.4",
67
- "esbuild": "^0.25.10"
74
+ "esbuild": "^0.25.10",
75
+ "typescript": "^6.0.3"
68
76
  },
69
77
  "devDependencies": {
70
78
  "@types/node": "^24.19.2",
71
- "typescript": "^5.9.3",
79
+ "vitepress": "^1.6.4",
72
80
  "workerd": "1.20261015.1",
73
81
  "wrangler": "4.151.0"
82
+ },
83
+ "optionalDependencies": {
84
+ "sucrase": "^3.35.1"
74
85
  }
75
86
  }